From 9ccd01c16072fc40a8aed129fe5c6fb99c30aa79 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Wed, 20 Nov 2024 22:12:17 +0300 Subject: [PATCH 01/24] uncertainty modeling with model disagreement --- asr/asr.py | 117 ++++++++++++++++------- asr/comparison.py | 221 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 +- server_ru.py | 4 +- tests/test_asr.py | 90 +++++++++--------- tests/test_asr_en.py | 5 +- tests/test_asr_ru.py | 5 +- 7 files changed, 358 insertions(+), 87 deletions(-) create mode 100644 asr/comparison.py diff --git a/asr/asr.py b/asr/asr.py index 9c015ab..fa9b790 100644 --- a/asr/asr.py +++ b/asr/asr.py @@ -164,8 +164,7 @@ def initialize_model_for_speech_segmentation(language: str = 'ru', model_info: O - for language='en': 'jonatasgrosman/wav2vec2-large-xlsr-53-english'. Returned value: an AutomaticSpeechRecognitionPipeline, to be called on mono sound with rate 16_000 - and argument `return_timestamps='word'`. In Pisets, only the output timestamps are used, not the - transcribed speech. + and argument `return_timestamps='word'`. NOTE: the pipeline should have the ability to process long audios. To achieve this, the method calls the `transformers.pipeline` factory with arguments `chunk_length_s=10, stride_length_s=(4, 2)`. @@ -306,11 +305,11 @@ def initialize_model_for_speech_recognition(language: str = 'ru', model_info: Op def select_word_groups( - words: List[Tuple[float, float]], + words: List[Tuple[float, float, str]], segment_size: float -) -> List[List[Tuple[float, float]]]: +) -> List[List[Tuple[float, float, str]]]: """ - Accepts a list of consecutive segments, each segment is a tuple (start_time, end_time). + Accepts a list of consecutive segments, each segment is a tuple (start_time, end_time, transcription). Iteratively splits the list of segments into "left" and "right" part by the largest pause between segments, then splits both parts the same way, and so on. A list of segments is splitted only if its total length @@ -322,7 +321,7 @@ def select_word_groups( Example: ``` - A, B, C, D, E, F = (3, 4), (4, 9), (12, 14), (14.5, 18), (18, 20), (28, 29) + A, B, C, D, E, F = (3, 4, ''), (4, 9, ''), (12, 14, ''), (14.5, 18, ''), (18, 20, ''), (28, 29, '') result = select_word_groups([A, B, C, D, E, F], segment_size=9) assert result == [[A, B], [C, D, E], [F]] @@ -364,19 +363,22 @@ def select_word_groups( def strip_segments( - segments: List[Tuple[float, float]], + segments: List[Tuple[float, float, str]], max_sound_duration: float ) -> List[Tuple[float, float]]: """ - Clips tuples (start_time, end_time) between (0, max_sound_duration). + Clips tuples (start_time, end_time, transcription) between (0, max_sound_duration). """ - return [(max(0.0, it[0]), min(it[1], max_sound_duration)) for it in segments] + return [ + (max(0.0, start), min(end, max_sound_duration), transcription) + for start, end, transcription in segments + ] def join_short_segments_to_long_ones( - segments: List[Tuple[float, float]], + segments: List[Tuple[float, float, str]], min_segment_size: float -) -> List[Tuple[float, float]]: +) -> List[Tuple[float, float, str]]: """ Iterates segments from left to right and merges two segments if two conditions are met: 1) The segment is shorter than `min_segment_size` @@ -392,7 +394,7 @@ def join_short_segments_to_long_ones( segment_idx = 0 while segment_idx < len(new_segments): - segment_start, segment_end = new_segments[segment_idx] + segment_start, segment_end, text = new_segments[segment_idx] if (segment_end - segment_start) < min_segment_size: if (segment_idx > 0) and (segment_idx < len(new_segments) - 1): distance_to_left = segment_start - new_segments[segment_idx - 1][1] @@ -401,7 +403,8 @@ def join_short_segments_to_long_ones( if distance_to_left < min_segment_size: new_segments[segment_idx - 1] = ( new_segments[segment_idx - 1][0], - segment_end + segment_end, + new_segments[segment_idx - 1][2] + ' ' + text ) _ = new_segments.pop(segment_idx) else: @@ -410,7 +413,8 @@ def join_short_segments_to_long_ones( if distance_to_right < min_segment_size: new_segments[segment_idx + 1] = ( segment_start, - new_segments[segment_idx + 1][1] + new_segments[segment_idx + 1][1], + text + ' ' + new_segments[segment_idx + 1][2] ) _ = new_segments.pop(segment_idx) else: @@ -420,7 +424,8 @@ def join_short_segments_to_long_ones( if distance_to_left < min_segment_size: new_segments[segment_idx - 1] = ( new_segments[segment_idx - 1][0], - segment_end + segment_end, + new_segments[segment_idx - 1][2] + ' ' + text ) _ = new_segments.pop(segment_idx) else: @@ -430,7 +435,8 @@ def join_short_segments_to_long_ones( if distance_to_right < min_segment_size: new_segments[segment_idx + 1] = ( segment_start, - new_segments[segment_idx + 1][1] + new_segments[segment_idx + 1][1], + text + ' ' + new_segments[segment_idx + 1][2] ) _ = new_segments.pop(segment_idx) else: @@ -449,18 +455,18 @@ def segment_sound( min_segment_size: float, max_segment_size: float, indent_for_silence: float = 0.5 -) -> List[Tuple[float, float]]: +) -> List[Tuple[float, float, str]]: """ Arguments: - mono_sound: 1D waveform with rate 16_000 (equals wav_io.TARGET_SAMPLING_FREQUENCY), possibly very long, and no shorter than asr.MIN_SOUND_LENGTH. - segmenter: an AutomaticSpeechRecognitionPipeline that can process long audios and - returns word timestamps. See `initialize_model_for_speech_segmentation` for details. + returns transcriptions and word timestamps. See `initialize_model_for_speech_segmentation` for details. - min_segment_size: see below - max_segment_size: see below - indent_for_silence: see below - Output: a list of tuples (start_time, end_time) for all found utterances, can be empty. + Output: a list of tuples (start_time, end_time, transcription) for all found utterances, can be empty. Performs the following actions: 1) Obtains speech segment boundaries by applying `segmenter` to `mono_sound`. @@ -506,23 +512,39 @@ def segment_sound( gc.collect() torch.cuda.empty_cache() - word_bounds = [(float(it['timestamp'][0]), float(it['timestamp'][1])) for it in output['chunks']] + word_bounds = [ + ( + float(it['timestamp'][0]), + float(it['timestamp'][1]), + str(it['text']) + ) + for it in output['chunks'] + ] if len(word_bounds) < 1: return [] if len(word_bounds) == 1: segment_start = word_bounds[0][0] - indent_for_silence segment_end = word_bounds[0][1] + indent_for_silence - return strip_segments([(segment_start, segment_end)], + full_transcription = word_bounds[0][2] + return strip_segments([(segment_start, segment_end, full_transcription)], mono_sound.shape[0] / TARGET_SAMPLING_FREQUENCY) if (word_bounds[-1][1] - word_bounds[0][0]) <= max_segment_size: segment_start = word_bounds[0][0] - indent_for_silence segment_end = word_bounds[-1][1] + indent_for_silence - return strip_segments([(segment_start, segment_end)], + full_transcription = ' '.join(text for _, _, text in word_bounds) + return strip_segments([(segment_start, segment_end, full_transcription)], mono_sound.shape[0] / TARGET_SAMPLING_FREQUENCY) word_groups = select_word_groups(word_bounds, max_segment_size) segments = strip_segments( - [(cur_group[0][0] - indent_for_silence, cur_group[-1][1] + indent_for_silence) for cur_group in word_groups], + [ + ( + cur_group[0][0] - indent_for_silence, + cur_group[-1][1] + indent_for_silence, + ' '.join(text for _, _, text in cur_group) + ) + for cur_group in word_groups + ], mono_sound.shape[0] / TARGET_SAMPLING_FREQUENCY ) n_segments = len(segments) @@ -531,8 +553,8 @@ def segment_sound( for idx in range(1, n_segments): if segments[idx - 1][1] > segments[idx][0]: overlap = segments[idx - 1][1] - segments[idx][0] - segments[idx - 1] = (segments[idx - 1][0], segments[idx - 1][1] - overlap / 2.0) - segments[idx] = (segments[idx][0] + overlap / 2.0, segments[idx][1]) + segments[idx - 1] = (segments[idx - 1][0], segments[idx - 1][1] - overlap / 2.0, segments[idx - 1][2]) + segments[idx] = (segments[idx][0] + overlap / 2.0, segments[idx][1], segments[idx][2]) return join_short_segments_to_long_ones(segments, min_segment_size) @@ -591,7 +613,7 @@ def transcribe( asr: Pipeline, min_segment_size: float, max_segment_size: float -) -> List[Tuple[float, float, str]]: +) -> List[Tuple[float, float, str, str]]: """ Transcribes a (possibly long) audio as follows: @@ -607,7 +629,7 @@ def transcribe( - mono_sound: 1D waveform with rate 16_000 (equals wav_io.TARGET_SAMPLING_FREQUENCY), no shorter than asr.MIN_SOUND_LENGTH. - segmenter: an AutomaticSpeechRecognitionPipeline that can process long audios and - returns word timestamps. See `initialize_model_for_speech_segmentation` for details. + returns transcriptions and word timestamps. See `initialize_model_for_speech_segmentation` for details. - voice_activity_detector: an AudioClassificationPipeline that can classify audios. See `initialize_model_for_speech_classification` for details. - asr: an AutomaticSpeechRecognitionPipeline that can return transcriptions. See @@ -615,22 +637,47 @@ def transcribe( - min_segment_size: a parameter for segment processing, see `segment_sound` for details. - max_segment_size: a parameter for segment processing, see `segment_sound` for details. - Output: a list of tuples (start_time, end_time, transcription) for all found utterances, - can be empty. + Output: a list of tuples (start_time, end_time, transcription_from_segmenter, transcription) + for all found utterances, can be empty. Example: ``` waveform = load_sound('tests/testdata/mono_sound.wav') segmenter = initialize_model_for_speech_segmentation() - voice_activity_detector = initialize_model_for_speech_classification() + vad = initialize_model_for_speech_classification() asr = initialize_model_for_speech_recognition('ru', 'openai/whisper-tiny') - transcribe(waveform, segmenter, vad, asr, min_segment_size=1, max_segment_size=5) + results = transcribe(waveform, segmenter, vad, asr, min_segment_size=1, max_segment_size=5) + print(results) >>> [ - (0.0, 4.18, 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.'), - (4.18, 6.8100000000000005, 'Большому другому и вану переселший годы.'), - (6.8100000000000005, 11.28, 'счастливые дни, как вешные воды, промчались они.') + ( + 0.0, + 4.18, + 'она советовала нам отнестись посему предмету к одному почтенному мужу', + 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.' + ), + ( + 4.18, + 6.8100000000000005, + 'бывшему другам ивану переселые годы', + 'Большому другому и вану переселший годы.' + ), + ( + 6.8100000000000005, + 11.28, + 'счастливые дни как вешние воды промчались они', + 'счастливые дни, как вешные воды, промчались они.' + ) ] + + from asr.comparison import compare, visualize_correction_suggestions + + for start, end, text_from_segmenter, text in results: + print(visualize_correction_suggestions(text, compare(text, text_from_segmenter))) + + >>> Она советовала нам {отнести|отнестись} {и спасену|посему} предмету к одному {почтиному|почтенному}{+} мужу. + {Большому другому и вану переселший|бывшему другам ивану переселые} годы. + счастливые дни, как {вешные|вешние}{+} воды, промчались они. ``` TODO when calling `voice_activity_detector` and `asr`, process all segments at once as @@ -691,7 +738,7 @@ def transcribe( results = list(filter( lambda it2: len(it2[2]) > 0, map( - lambda it: (it[0][0], it[0][1], it[1].strip()), + lambda it: (it[0][0], it[0][1], it[0][2], it[1].strip()), zip(segments_with_speech, recognized_transcriptions) ) )) diff --git a/asr/comparison.py b/asr/comparison.py new file mode 100644 index 0000000..371c4ed --- /dev/null +++ b/asr/comparison.py @@ -0,0 +1,221 @@ +from dataclasses import dataclass +import difflib +import razdel +import numpy as np + +def text_to_words(text: str) -> tuple[list[razdel.substring.Substring], list[bool]]: + """ + Accepts a text, returns a list of tokens, where each token is either a word, + or a punctuation mark. Additionally returns a boolean mask: is each token a + punctuation mark? (True if does not contain alnum characters) + """ + tokens = list(razdel.tokenize(text)) + for t in tokens: + t.text = t.text.lower() + is_a_punct = [all(not c.isalnum() for c in token.text) for token in tokens] + return tokens, is_a_punct + +@dataclass +class Match: + """ + Represents a matching part between two lists: + list1[start1:end1] matches list2[start2:end2] + + If self.len1 == self.len2, the fragments may be additionally be marked as + equal or not equal (if not equal this is a replacement operation). + """ + start1: int + end1: int + start2: int + end2: int + is_equal: bool + + def __post_init__(self): + if self.is_equal: + assert self.len1 == self.len2 + + @property + def len1(self) -> int: + return self.end1 - self.start1 + + @property + def len2(self) -> int: + return self.end2 - self.start2 + + @property + def is_replace(self) -> bool: + return self.len1 > 0 and self.len2 > 0 and not self.is_equal + + @property + def is_insert(self) -> bool: + return self.len1 == 0 + + @property + def is_delete(self) -> bool: + return self.len2 == 0 + +@dataclass +class CorrectionSuggestion: + """ + A suggestion to correct some text in some place. + """ + start_pos: int + end_pos: int + suggestion: str + +def words_close_match(word1, word2) -> bool: + return difflib.SequenceMatcher(None, word1, word2).ratio() >= 0.5 + +def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: + """ + Arguments: + - text1: an ASR prediction + - text2: another ASR prediction + + Returns a list of suggestions to replace, delete or insert something in the `text1`, + based on the difference between both texts. + + Example: + ``` + text1 = 'Раз, два, трии! Привет! Это "тестовый" текст. Корректор А. Кулакова.' + text2 = 'ТРИ ПРИВЕТ ЭТО ЭЭ ТЕСТОВЫЙ ТЕКС' + + from asr.comparison import compare, visualize_correction_suggestions + suggestions = compare(text1, text2) + print(visualize_correction_suggestions(text1, suggestions)) + + >>> {+Раз, два}, {трии|ТРИ}! Привет! Это {+ЭЭ} "тестовый" {текст|ТЕКС}. {+Корректор А. Кулакова}. + ``` + """ + # parsing into words and punctuation marks + tokens1, is_punct1 = text_to_words(text1) + tokens2, is_punct2 = text_to_words(text2) + + # considering only words + words1 = np.array(tokens1)[~np.array(is_punct1)].tolist() + words2 = np.array(tokens2)[~np.array(is_punct2)].tolist() + + # get operations (delete, insert, replace, equal) + matcher = difflib.SequenceMatcher( + None, + [t.text for t in words1], + [t.text for t in words2], + autojunk=False + ) + orig_opcodes = matcher.get_opcodes() + + ops = [ + Match(start1, end1, start2, end2, is_equal=(op == 'equal')) + for op, start1, end1, start2, end2 in orig_opcodes + ] + + # now we have a list of Match-es between words1 and words2 + + for _ in range(10): + # we split some "replace" ops into two ops, such as + # replace('aaaa bbb ccc', 'aaa') -> replace('aaaa', 'aaa') + delete('bbb ccc') + new_ops: list[Match] = [] + for match in ops: + start1, end1, start2, end2 = match.start1, match.end1, match.start2, match.end2 + if match.is_equal: + new_ops.append(Match(start1, end1, start2, end2, is_equal=True)) + elif match.is_insert or match.is_delete: + new_ops.append(Match(start1, end1, start2, end2, is_equal=False)) + elif match.is_replace: + if words_close_match(words1[start1].text, words2[start2].text): + new_ops.append(Match(start1, start1 + 1, start2, start2 + 1, is_equal=False)) + if end1 > start1 + 1 or end2 > start2 + 1: + new_ops.append(Match(start1 + 1, end1, start2 + 1, end2, is_equal=False)) + elif words_close_match(words1[end1 - 1].text, words2[end2 - 1].text): + if end1 - 1 > start1 or end2 - 1 > start2: + new_ops.append(Match(start1, end1 - 1, start2, end2 - 1, is_equal=False)) + new_ops.append(Match(end1 - 1, end1, end2 - 1, end2, is_equal=False)) + else: + new_ops.append(Match(start1, end1, start2, end2, is_equal=False)) + orig_ops = ops + ops = new_ops + if ops == orig_ops: + break + + # consider only non-equal matches + diffs = [op for op in ops if not op.is_equal] + + # get the positions in the original text, convert to correction suggestions + suggestions: list[CorrectionSuggestion] = [] + + for diff in diffs: + # position + if diff.start1 != diff.end1: + text1_start_pos = words1[diff.start1].start + text1_end_pos = words1[diff.end1 - 1].stop + else: + # suggestion to add + if diff.end1 > 0: + add_mode = 'append' + pos = words1[diff.end1 - 1].stop + else: + add_mode = 'prepend' + pos = words1[diff.end1].start + text1_start_pos = pos + text1_end_pos = pos + + # suggestion + if diff.start2 == diff.end2: + suggestion = '' + else: + text2_start_idx = words2[diff.start2].start + text2_end_idx = words2[diff.end2 - 1].stop + suggestion = text2[text2_start_idx:text2_end_idx] + if diff.start1 == diff.end1: + # suggestion to add + if add_mode == 'append': + suggestion = ' ' + suggestion + elif add_mode == 'prepend': + suggestion = suggestion + ' ' + + suggestions.append(CorrectionSuggestion(text1_start_pos, text1_end_pos, suggestion)) + + return suggestions + +def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSuggestion]) -> str: + """ + Visualize suggestions in {brackets}. Example: + + ``` + text1 = 'она советовала нам отнестись посему предмету к одному почтенному мужу' + text2 = 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.' + suggestions = compare(text1, text2) + print(visualize_correction_suggestions(text1, suggestions)) + + >>> 'она советовала нам {отнестись|отнести} {+и} {посему|спасену} предмету к одному {почтенному|почтиному} мужу' + ``` + """ + result = '' + for i, suggestion in enumerate(suggestions): + start = suggestion.start_pos + end = suggestion.end_pos + prev_end = suggestions[i - 1].end_pos if i > 0 else None + + result += text[prev_end:start] + + hypothesis1 = text[start:end] + hypothesis2 = suggestion.suggestion + if len(hypothesis1) == 0: + # suggestion to add + visualized_suggestion = '{+' + hypothesis2.strip() + '}' + if hypothesis2.startswith(' '): + visualized_suggestion = ' ' + visualized_suggestion + if hypothesis2.endswith(' '): + visualized_suggestion = visualized_suggestion + ' ' + elif len(hypothesis2) == 0: + # suggestion to remove + visualized_suggestion = '{+' + hypothesis1 + '}' + else: + # suggestion to correct + visualized_suggestion = '{' + hypothesis1 + '|' + hypothesis2 + '}' + + result += visualized_suggestion + + result += text[end:] + + return result \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 71501c2..a9355c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,5 @@ torchaudio==2.3.1 torchvision==0.18.1 tokenizers>=0.19.1 transformers>=4.41.2 -webrtcvad>=2.0.10 \ No newline at end of file +webrtcvad>=2.0.10 +setuptools \ No newline at end of file diff --git a/server_ru.py b/server_ru.py index 5cea57e..4e51ca0 100644 --- a/server_ru.py +++ b/server_ru.py @@ -150,8 +150,8 @@ async def create_result_file(input_sound, segmenter, vad, asr, task_id): texts_with_timestamps = transcribe_speech(input_sound, segmenter, vad, asr, MIN_FRAME_SIZE, MAX_FRAME_SIZE) output_filename = task_id + '.docx' doc = Document() - for start_time, end_time, sentence_text in texts_with_timestamps: - line = f'{start_time:.2f} - {end_time:.2f} - {sentence_text}' + for start_time, end_time, text_from_segmenter, text_final in texts_with_timestamps: + line = f'{start_time:.2f} - {end_time:.2f} - {text_final}' doc.add_paragraph(line) doc.add_paragraph('') diff --git a/tests/test_asr.py b/tests/test_asr.py index 06730fc..6ecd957 100644 --- a/tests/test_asr.py +++ b/tests/test_asr.py @@ -19,47 +19,47 @@ class TestASR(unittest.TestCase): def test_strip_segments_pos01(self): max_sound_duration = 5.5 - input_segments = [(0.1, 0.9), (0.95, 3.0), (3.0, 5.0)] - target_segments = [(0.1, 0.9), (0.95, 3.0), (3.0, 5.0)] + input_segments = [(0.1, 0.9, ''), (0.95, 3.0, ''), (3.0, 5.0, '')] + target_segments = [(0.1, 0.9, ''), (0.95, 3.0, ''), (3.0, 5.0, '')] predicted_segments = strip_segments(input_segments, max_sound_duration) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(target_segments)) for idx in range(len(target_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], target_segments[idx][0]) self.assertAlmostEqual(predicted_segments[idx][1], target_segments[idx][1]) def test_strip_segments_pos02(self): max_sound_duration = 5.5 - input_segments = [(-0.1, 0.9), (0.95, 3.0), (3.0, 5.0)] - target_segments = [(0.0, 0.9), (0.95, 3.0), (3.0, 5.0)] + input_segments = [(-0.1, 0.9, ''), (0.95, 3.0, ''), (3.0, 5.0, '')] + target_segments = [(0.0, 0.9, ''), (0.95, 3.0, ''), (3.0, 5.0, '')] predicted_segments = strip_segments(input_segments, max_sound_duration) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(target_segments)) for idx in range(len(target_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], target_segments[idx][0]) self.assertAlmostEqual(predicted_segments[idx][1], target_segments[idx][1]) def test_strip_segments_pos03(self): max_sound_duration = 5.5 - input_segments = [(0.1, 0.9), (0.95, 3.0), (3.0, 5.8)] - target_segments = [(0.1, 0.9), (0.95, 3.0), (3.0, 5.5)] + input_segments = [(0.1, 0.9, ''), (0.95, 3.0, ''), (3.0, 5.8, '')] + target_segments = [(0.1, 0.9, ''), (0.95, 3.0, ''), (3.0, 5.5, '')] predicted_segments = strip_segments(input_segments, max_sound_duration) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(target_segments)) for idx in range(len(target_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], target_segments[idx][0]) self.assertAlmostEqual(predicted_segments[idx][1], target_segments[idx][1]) def test_select_word_groups_pos01(self): segment_size = 2 - words = [(0.1, 0.5), (0.7, 1.0), (1.1, 2.3), (2.7, 2.8), (3.6, 3.8), (3.8, 4.0)] - target_groups = [[(0.1, 0.5)], [(0.7, 1.0), (1.1, 2.3)], [(2.7, 2.8)], [(3.6, 3.8), (3.8, 4.0)]] + words = [(0.1, 0.5, ''), (0.7, 1.0, ''), (1.1, 2.3, ''), (2.7, 2.8, ''), (3.6, 3.8, ''), (3.8, 4.0, '')] + target_groups = [[(0.1, 0.5, '')], [(0.7, 1.0, ''), (1.1, 2.3, '')], [(2.7, 2.8, '')], [(3.6, 3.8, ''), (3.8, 4.0, '')]] predicted_groups = select_word_groups(words, segment_size) self.assertIsInstance(predicted_groups, list) self.assertEqual(len(predicted_groups), len(target_groups)) @@ -68,14 +68,14 @@ def test_select_word_groups_pos01(self): self.assertEqual(len(predicted_groups[group_idx]), len(target_groups[group_idx])) for word_idx in range(len(target_groups[group_idx])): self.assertIsInstance(predicted_groups[group_idx][word_idx], tuple) - self.assertEqual(len(predicted_groups[group_idx][word_idx]), 2) + self.assertEqual(len(predicted_groups[group_idx][word_idx]), 3) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][0], target_groups[group_idx][word_idx][0]) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][1], target_groups[group_idx][word_idx][1]) def test_select_word_groups_pos02(self): segment_size = 2 - words = [(0.1, 0.5), (0.7, 1.0)] - target_groups = [[(0.1, 0.5), (0.7, 1.0)]] + words = [(0.1, 0.5, ''), (0.7, 1.0, '')] + target_groups = [[(0.1, 0.5, ''), (0.7, 1.0, '')]] predicted_groups = select_word_groups(words, segment_size) self.assertIsInstance(predicted_groups, list) self.assertEqual(len(predicted_groups), len(target_groups)) @@ -84,14 +84,14 @@ def test_select_word_groups_pos02(self): self.assertEqual(len(predicted_groups[group_idx]), len(target_groups[group_idx])) for word_idx in range(len(target_groups[group_idx])): self.assertIsInstance(predicted_groups[group_idx][word_idx], tuple) - self.assertEqual(len(predicted_groups[group_idx][word_idx]), 2) + self.assertEqual(len(predicted_groups[group_idx][word_idx]), 3) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][0], target_groups[group_idx][word_idx][0]) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][1], target_groups[group_idx][word_idx][1]) def test_select_word_groups_pos03(self): segment_size = 2 - words = [(0.1, 0.5), (3.7, 4.0)] - target_groups = [[(0.1, 0.5)], [(3.7, 4.0)]] + words = [(0.1, 0.5, ''), (3.7, 4.0, '')] + target_groups = [[(0.1, 0.5, '')], [(3.7, 4.0, '')]] predicted_groups = select_word_groups(words, segment_size) self.assertIsInstance(predicted_groups, list) self.assertEqual(len(predicted_groups), len(target_groups)) @@ -100,14 +100,14 @@ def test_select_word_groups_pos03(self): self.assertEqual(len(predicted_groups[group_idx]), len(target_groups[group_idx])) for word_idx in range(len(target_groups[group_idx])): self.assertIsInstance(predicted_groups[group_idx][word_idx], tuple) - self.assertEqual(len(predicted_groups[group_idx][word_idx]), 2) + self.assertEqual(len(predicted_groups[group_idx][word_idx]), 3) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][0], target_groups[group_idx][word_idx][0]) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][1], target_groups[group_idx][word_idx][1]) def test_select_word_groups_pos04(self): segment_size = 2 - words = [(0.1, 4.0)] - target_groups = [[(0.1, 4.0)]] + words = [(0.1, 4.0, '')] + target_groups = [[(0.1, 4.0, '')]] predicted_groups = select_word_groups(words, segment_size) self.assertIsInstance(predicted_groups, list) self.assertEqual(len(predicted_groups), len(target_groups)) @@ -116,7 +116,7 @@ def test_select_word_groups_pos04(self): self.assertEqual(len(predicted_groups[group_idx]), len(target_groups[group_idx])) for word_idx in range(len(target_groups[group_idx])): self.assertIsInstance(predicted_groups[group_idx][word_idx], tuple) - self.assertEqual(len(predicted_groups[group_idx][word_idx]), 2) + self.assertEqual(len(predicted_groups[group_idx][word_idx]), 3) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][0], target_groups[group_idx][word_idx][0]) self.assertAlmostEqual(predicted_groups[group_idx][word_idx][1], target_groups[group_idx][word_idx][1]) @@ -273,98 +273,98 @@ def test_remove_oscillatory_hallucinations_pos02(self): self.assertEqual(res, true_text) def test_join_short_segments_to_long_ones_pos01(self): - source_segments = [(0.5, 2.5), (2.7, 3.92), (5.0, 7.5)] - true_segments = [(0.5, 2.5), (2.7, 3.92), (5.0, 7.5)] + source_segments = [(0.5, 2.5, ''), (2.7, 3.92, ''), (5.0, 7.5, '')] + true_segments = [(0.5, 2.5, ''), (2.7, 3.92, ''), (5.0, 7.5, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos02(self): - source_segments = [(0.5, 1.1), (2.7, 3.92), (5.0, 7.5)] - true_segments = [(0.5, 1.1), (2.7, 3.92), (5.0, 7.5)] + source_segments = [(0.5, 1.1, ''), (2.7, 3.92, ''), (5.0, 7.5, '')] + true_segments = [(0.5, 1.1, ''), (2.7, 3.92, ''), (5.0, 7.5, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos03(self): - source_segments = [(0.5, 1.1), (1.7, 3.92), (5.0, 7.5)] - true_segments = [(0.5, 3.92), (5.0, 7.5)] + source_segments = [(0.5, 1.1, ''), (1.7, 3.92, ''), (5.0, 7.5, '')] + true_segments = [(0.5, 3.92, ''), (5.0, 7.5, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos04(self): - source_segments = [(0.5, 2.5), (2.7, 2.92), (5.0, 7.5)] - true_segments = [(0.5, 2.92), (5.0, 7.5)] + source_segments = [(0.5, 2.5, ''), (2.7, 2.92, ''), (5.0, 7.5, '')] + true_segments = [(0.5, 2.92, ''), (5.0, 7.5, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos05(self): - source_segments = [(0.5, 2.5), (2.7, 2.92), (3.0, 7.5)] - true_segments = [(0.5, 2.5), (2.7, 7.5)] + source_segments = [(0.5, 2.5, ''), (2.7, 2.92, ''), (3.0, 7.5, '')] + true_segments = [(0.5, 2.5, ''), (2.7, 7.5, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos06(self): - source_segments = [(0.5, 2.5), (2.7, 3.92), (4.0, 4.3)] - true_segments = [(0.5, 2.5), (2.7, 4.3)] + source_segments = [(0.5, 2.5, ''), (2.7, 3.92, ''), (4.0, 4.3, '')] + true_segments = [(0.5, 2.5, ''), (2.7, 4.3, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos07(self): - source_segments = [(0.5, 2.5), (2.7, 3.92), (5.0, 5.5)] - true_segments = [(0.5, 2.5), (2.7, 3.92), (5.0, 5.5)] + source_segments = [(0.5, 2.5, ''), (2.7, 3.92, ''), (5.0, 5.5, '')] + true_segments = [(0.5, 2.5, ''), (2.7, 3.92, ''), (5.0, 5.5, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) def test_join_short_segments_to_long_ones_pos08(self): - source_segments = [(0.5, 0.6)] - true_segments = [(0.5, 0.6)] + source_segments = [(0.5, 0.6, '')] + true_segments = [(0.5, 0.6, '')] predicted_segments = join_short_segments_to_long_ones(source_segments, 1) self.assertIsInstance(predicted_segments, list) self.assertEqual(len(predicted_segments), len(true_segments)) for idx in range(len(true_segments)): self.assertIsInstance(predicted_segments[idx], tuple) - self.assertEqual(len(predicted_segments[idx]), 2) + self.assertEqual(len(predicted_segments[idx]), 3) self.assertAlmostEqual(predicted_segments[idx][0], true_segments[idx][0], delta=1e-6) self.assertAlmostEqual(predicted_segments[idx][1], true_segments[idx][1], delta=1e-6) diff --git a/tests/test_asr_en.py b/tests/test_asr_en.py index 8bd386c..f143807 100644 --- a/tests/test_asr_en.py +++ b/tests/test_asr_en.py @@ -80,14 +80,15 @@ def test_recognize_pos01(self): self.assertIsInstance(res, list) self.assertEqual(len(res), 1) self.assertIsInstance(res[0], tuple) - self.assertEqual(len(res[0]), 3) + self.assertEqual(len(res[0]), 4) self.assertIsInstance(res[0][0], float) self.assertIsInstance(res[0][1], float) self.assertIsInstance(res[0][2], str) + self.assertIsInstance(res[0][3], str) self.assertLessEqual(0.0, res[0][0]) self.assertLess(res[0][0], res[0][1]) self.assertLessEqual(res[0][1], self.sound.shape[0] / TARGET_SAMPLING_FREQUENCY) - predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(res[0][2].lower()))) + predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(res[0][3].lower()))) self.assertEqual(predicted_words, true_words) def test_recognize_pos02(self): diff --git a/tests/test_asr_ru.py b/tests/test_asr_ru.py index bd1ae37..ddd51b7 100644 --- a/tests/test_asr_ru.py +++ b/tests/test_asr_ru.py @@ -80,14 +80,15 @@ def test_recognize_pos01(self): self.assertIsInstance(res, list) self.assertEqual(len(res), 1) self.assertIsInstance(res[0], tuple) - self.assertEqual(len(res[0]), 3) + self.assertEqual(len(res[0]), 4) self.assertIsInstance(res[0][0], float) self.assertIsInstance(res[0][1], float) self.assertIsInstance(res[0][2], str) + self.assertIsInstance(res[0][3], str) self.assertLessEqual(0.0, res[0][0]) self.assertLess(res[0][0], res[0][1]) self.assertLessEqual(res[0][1], self.sound.shape[0] / TARGET_SAMPLING_FREQUENCY) - predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(res[0][2].lower()))) + predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(res[0][3].lower()))) self.assertEqual(predicted_words, true_words) def test_recognize_pos02(self): From c177450e1de5c52b9ecb42f611d62d87049a1e19 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Wed, 20 Nov 2024 22:27:01 +0300 Subject: [PATCH 02/24] docstring update --- asr/comparison.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/asr/comparison.py b/asr/comparison.py index 371c4ed..4500841 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -179,7 +179,12 @@ def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSuggestion]) -> str: """ - Visualize suggestions in {brackets}. Example: + Visualize suggestions in {brackets}. + - {aaa|bbb} - suggest to replace aaa to bbb + - {aaa} - suggest to remove aaa + - {+aaa} - suggest to insert aaa (not present in `text`) + + Example: ``` text1 = 'она советовала нам отнестись посему предмету к одному почтенному мужу' From 581dc6c4c0f6956bf323cc74cd35456a1a40ae79 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Wed, 20 Nov 2024 22:37:01 +0300 Subject: [PATCH 03/24] fixes in compare function --- asr/comparison.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/asr/comparison.py b/asr/comparison.py index 4500841..b85e422 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -22,7 +22,7 @@ class Match: list1[start1:end1] matches list2[start2:end2] If self.len1 == self.len2, the fragments may be additionally be marked as - equal or not equal (if not equal this is a replacement operation). + equal or not equal (if not equal this Match represents a replacement operation). """ start1: int end1: int @@ -57,7 +57,9 @@ def is_delete(self) -> bool: @dataclass class CorrectionSuggestion: """ - A suggestion to correct some text in some place. + A suggestion to correct some text in some place, by replacing `text[start_pos:end_pos]` + with `suggestion`. If `start_pos == end_pos`, this is a suggestion to add a text in + `start_pos` position. """ start_pos: int end_pos: int @@ -117,11 +119,9 @@ def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: new_ops: list[Match] = [] for match in ops: start1, end1, start2, end2 = match.start1, match.end1, match.start2, match.end2 - if match.is_equal: - new_ops.append(Match(start1, end1, start2, end2, is_equal=True)) - elif match.is_insert or match.is_delete: - new_ops.append(Match(start1, end1, start2, end2, is_equal=False)) - elif match.is_replace: + if not match.is_replace: + new_ops.append(match) + else: if words_close_match(words1[start1].text, words2[start2].text): new_ops.append(Match(start1, start1 + 1, start2, start2 + 1, is_equal=False)) if end1 > start1 + 1 or end2 > start2 + 1: @@ -131,7 +131,7 @@ def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: new_ops.append(Match(start1, end1 - 1, start2, end2 - 1, is_equal=False)) new_ops.append(Match(end1 - 1, end1, end2 - 1, end2, is_equal=False)) else: - new_ops.append(Match(start1, end1, start2, end2, is_equal=False)) + new_ops.append(match) orig_ops = ops ops = new_ops if ops == orig_ops: From 04b758557c3f166ac94770d16dab64d1d37645c4 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Thu, 21 Nov 2024 08:19:45 +0300 Subject: [PATCH 04/24] docstring update --- asr/comparison.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/asr/comparison.py b/asr/comparison.py index b85e422..db91a7b 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -8,6 +8,8 @@ def text_to_words(text: str) -> tuple[list[razdel.substring.Substring], list[boo Accepts a text, returns a list of tokens, where each token is either a word, or a punctuation mark. Additionally returns a boolean mask: is each token a punctuation mark? (True if does not contain alnum characters) + + Tested for Ru and En languages. """ tokens = list(razdel.tokenize(text)) for t in tokens: From 289d1882530f0dcb3c71ba0cb5f044c9ae438613 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Thu, 21 Nov 2024 08:43:14 +0300 Subject: [PATCH 05/24] fix dataclass --- asr/comparison.py | 1 + 1 file changed, 1 insertion(+) diff --git a/asr/comparison.py b/asr/comparison.py index db91a7b..675667e 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -33,6 +33,7 @@ class Match: is_equal: bool def __post_init__(self): + assert self.len1 > 0 or self.len2 > 0 if self.is_equal: assert self.len1 == self.len2 From 00cb2b4f67ece8dec7bdcbed03d3b148d6f28d1d Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Thu, 21 Nov 2024 08:51:53 +0300 Subject: [PATCH 06/24] docstring update --- asr/comparison.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/asr/comparison.py b/asr/comparison.py index 675667e..00bc632 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -78,7 +78,9 @@ def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: - text2: another ASR prediction Returns a list of suggestions to replace, delete or insert something in the `text1`, - based on the difference between both texts. + based on the difference between both texts. So, this function is not symmetric, + since output suggestions contain positions in the `text1`. Punctuation is not compared, + so the punctiation from `text2` is knever used. Example: ``` From 6784d5e0f10c0b1662cd4b542f16f565416c3786 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Thu, 21 Nov 2024 08:53:42 +0300 Subject: [PATCH 07/24] docstring update --- asr/comparison.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/asr/comparison.py b/asr/comparison.py index 00bc632..b849861 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -78,9 +78,9 @@ def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: - text2: another ASR prediction Returns a list of suggestions to replace, delete or insert something in the `text1`, - based on the difference between both texts. So, this function is not symmetric, + based on the difference between both texts. Thus, this function is not symmetric, since output suggestions contain positions in the `text1`. Punctuation is not compared, - so the punctiation from `text2` is knever used. + which means that the punctiation from `text2` is never used. Example: ``` From b29c227d5eae447d3b5232a8fa4fc16dfd37e26f Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sun, 24 Nov 2024 18:20:52 +0300 Subject: [PATCH 08/24] uncertainty code updates --- asr/asr.py | 5 +- asr/comparison.py | 467 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 356 insertions(+), 116 deletions(-) diff --git a/asr/asr.py b/asr/asr.py index fa9b790..cf7c867 100644 --- a/asr/asr.py +++ b/asr/asr.py @@ -599,7 +599,7 @@ def recognize_sounds(sounds: List[np.ndarray], recognizer: Pipeline) -> List[str raise ValueError(err_msg) all_transcriptions = [] - for cur_sound in tqdm(sounds): + for cur_sound in sounds: # tqdm(sounds): all_transcriptions.append(recognizer(cur_sound)['text']) gc.collect() torch.cuda.empty_cache() @@ -642,6 +642,9 @@ def transcribe( Example: ``` + from wav_io.wav_io import load_sound + from asr.asr import * + waveform = load_sound('tests/testdata/mono_sound.wav') segmenter = initialize_model_for_speech_segmentation() vad = initialize_model_for_speech_classification() diff --git a/asr/comparison.py b/asr/comparison.py index b849861..e82bf92 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -1,30 +1,82 @@ +from __future__ import annotations + from dataclasses import dataclass import difflib import razdel -import numpy as np -def text_to_words(text: str) -> tuple[list[razdel.substring.Substring], list[bool]]: +@dataclass +class Substring: + """ + Intended to store information about where words or punctuation marks are located + in a text. + + This class is an extension of razdel.substring.Substring to store additional flags. """ - Accepts a text, returns a list of tokens, where each token is either a word, - or a punctuation mark. Additionally returns a boolean mask: is each token a - punctuation mark? (True if does not contain alnum characters) + start: int + stop: int + text: str + is_punct: bool - Tested for Ru and En languages. +@dataclass +class TokenizedText: """ - tokens = list(razdel.tokenize(text)) - for t in tokens: - t.text = t.text.lower() - is_a_punct = [all(not c.isalnum() for c in token.text) for token in tokens] - return tokens, is_a_punct + Stores text and positions of tokens (words and punctuation marks). + + Tokenization is performed using Razdel (tested for Ru and En). A token + is considered a punctuation mark if it does not contain letters or digits. + + Example: + ``` + tokenized = TokenizedText.from_text('Это "тестовый" текст. !!') + tokenized.tokens + + >>> [Substring(start=0, stop=3, text='это', is_punct=False), + Substring(start=4, stop=5, text='"', is_punct=True), + Substring(start=5, stop=13, text='тестовый', is_punct=False), + Substring(start=13, stop=14, text='"', is_punct=True), + Substring(start=15, stop=20, text='текст', is_punct=False), + Substring(start=20, stop=21, text='.', is_punct=True), + Substring(start=22, stop=24, text='!!', is_punct=True)] + + tokenized.get_words() + + >>> [Substring(start=0, stop=3, text='это', is_punct=False), + Substring(start=5, stop=13, text='тестовый', is_punct=False), + Substring(start=15, stop=20, text='текст', is_punct=False)] + ``` + """ + text: str + tokens: list[Substring] + + def get_words(self) -> list[Substring]: + """ + Returns a list of words (skips punctuation marks). + """ + return [t for t in self.tokens if not t.is_punct] + + @classmethod + def from_text(cls, text: str) -> TokenizedText: + tokens = [ + Substring( + start=t.start, + stop=t.stop, + text=t.text.lower(), + is_punct=all(not c.isalnum() for c in t.text) + ) + for t in razdel.tokenize(text) + ] + return TokenizedText(text=text, tokens=tokens) @dataclass -class Match: +class WordLevelMatch: """ - Represents a matching part between two lists: - list1[start1:end1] matches list2[start2:end2] + A dataclass variant of `difflib.SequenceMatcher` outputs. Represents a matching + part between two lists: `list1[start1:end1]` matches `list2[start2:end2]` + + If self.len1 == self.len2, may be additionally be marked as equal or not equal + match (if not equal this Match represents a replacement operation). - If self.len1 == self.len2, the fragments may be additionally be marked as - equal or not equal (if not equal this Match represents a replacement operation). + Use case: usually indices in Match are word indices (not character indices). """ start1: int end1: int @@ -58,129 +110,311 @@ def is_delete(self) -> bool: return self.len2 == 0 @dataclass -class CorrectionSuggestion: +class MultipleTextsAlignment: """ - A suggestion to correct some text in some place, by replacing `text[start_pos:end_pos]` - with `suggestion`. If `start_pos == end_pos`, this is a suggestion to add a text in - `start_pos` position. - """ - start_pos: int - end_pos: int - suggestion: str + Stores text, divided into words, and a list of found matches between the words. -def words_close_match(word1, word2) -> bool: - return difflib.SequenceMatcher(None, word1, word2).ratio() >= 0.5 + In the following example, we have two texts: + ``` + text_1 = 'Aaaa aa, bb-bb' + text_2 = 'Aa bbbb cc cc!' + ``` -def compare(text1: str, text2: str) -> list[CorrectionSuggestion]: - """ - Arguments: - - text1: an ASR prediction - - text2: another ASR prediction + We split them into words with `TokenizedText`, which uses Razdel library under the hood. + `TokenizedText` keeps a list of tokens, each token is either a lower-case word, or a + punctuation mark. + ``` + tokenized_text_1 = TokenizedText.from_text(text_1) + tokenized_text_2 = TokenizedText.from_text(text_2) + print(tokenized_text_1.tokens, tokenized_text_2.tokens) + + >>> [ + Substring(start=0, stop=4, text='aaaa', is_punct=False), + Substring(start=5, stop=7, text='aa', is_punct=False), + Substring(start=7, stop=8, text=',', is_punct=True), + Substring(start=9, stop=14, text='bb-bb', is_punct=False) + ], [ + Substring(start=0, stop=2, text='aa', is_punct=False), + Substring(start=3, stop=7, text='bbbb', is_punct=False), + Substring(start=8, stop=10, text='cc', is_punct=False), + Substring(start=11, stop=13, text='cc', is_punct=False), + Substring(start=13, stop=14, text='!', is_punct=True) + ] + ``` + + We then match only words (a method `TokenizedText.get_words()`) in both texts: + ``` + word_matches=MultipleTextsAlighment.get_matches( + tokenized_text_1.get_words(), + tokenized_text_2.get_words() + ) + print(word_matches) - Returns a list of suggestions to replace, delete or insert something in the `text1`, - based on the difference between both texts. Thus, this function is not symmetric, - since output suggestions contain positions in the `text1`. Punctuation is not compared, - which means that the punctiation from `text2` is never used. + >>> [ + WordLevelMatch(start1=0, end1=1, start2=0, end2=0, is_equal=False), + WordLevelMatch(start1=1, end1=2, start2=0, end2=1, is_equal=True), + WordLevelMatch(start1=2, end1=3, start2=1, end2=2, is_equal=False), + WordLevelMatch(start1=3, end1=3, start2=2, end2=4, is_equal=False) + ] + ``` - Example: + For example, consider the last `WordLevelMatch`. It means that words [3:3] in + `tokenized_text_1.tokens` match words [2:4] in `tokenized_text_2.tokens`. Since + the first span is empty, this means that the last two words "cc" and "cc" in the + second text have no counterparts in the first text. As for the other matches: + + - The 1st match is a deletion (the word "aaaa" is present only in the first text) + - The 2nd match is an equality (the word "aa" is present in both texts) + - The 3rd match is a replacement (the word "bb-bb" is replaced by "bbbb") + - The 4th match is an insertion (the words "cc cc" are present only in the second text) + + Now we can construct `MultipleTextsAlighment`: + ``` + alignment = MultipleTextsAlignment(tokenized_text_1, tokenized_text_2, word_matches) ``` - text1 = 'Раз, два, трии! Привет! Это "тестовый" текст. Корректор А. Кулакова.' - text2 = 'ТРИ ПРИВЕТ ЭТО ЭЭ ТЕСТОВЫЙ ТЕКС' - from asr.comparison import compare, visualize_correction_suggestions - suggestions = compare(text1, text2) - print(visualize_correction_suggestions(text1, suggestions)) + Or we can get the same result from the original texts using `.from_strings()`: + ``` + alignment = MultipleTextsAlignment.from_strings(text_1, text_2) + ``` - >>> {+Раз, два}, {трии|ТРИ}! Привет! Это {+ЭЭ} "тестовый" {текст|ТЕКС}. {+Корректор А. Кулакова}. + Now we can obtain the corrections that the second text suggests when compared with the + first text. Here the positions (`start_pos`, `end_pos`) are character positions in the + original `text_1`. ``` - """ - # parsing into words and punctuation marks - tokens1, is_punct1 = text_to_words(text1) - tokens2, is_punct2 = text_to_words(text2) - - # considering only words - words1 = np.array(tokens1)[~np.array(is_punct1)].tolist() - words2 = np.array(tokens2)[~np.array(is_punct2)].tolist() - - # get operations (delete, insert, replace, equal) - matcher = difflib.SequenceMatcher( - None, - [t.text for t in words1], - [t.text for t in words2], - autojunk=False - ) - orig_opcodes = matcher.get_opcodes() + suggestions = alignment.get_correction_suggestions() + print(suggestions) - ops = [ - Match(start1, end1, start2, end2, is_equal=(op == 'equal')) - for op, start1, end1, start2, end2 in orig_opcodes + >>> [ + CorrectionSuggestion(start_pos=0, end_pos=4, suggestion=''), + CorrectionSuggestion(start_pos=9, end_pos=14, suggestion='bbbb'), + CorrectionSuggestion(start_pos=14, end_pos=14, suggestion=' cc cc') ] + ``` + + We can visualize them in brackets, so that we can see all the matches: the deletion, + the equality, the replacement and the insertion: + ``` + print(visualize_correction_suggestions(text_1, suggestions)) + + >>> '{Aaaa} aa, {bb-bb|bbbb} {+cc cc}' + ``` + """ + text1: TokenizedText + text2: TokenizedText + matches: list[WordLevelMatch] + + @classmethod + def from_strings(cls, text1: str, text2: str) -> MultipleTextsAlignment: + return MultipleTextsAlignment( + text1=(tokenized_text_1 := TokenizedText.from_text(text1)), + text2=(tokenized_text_2 := TokenizedText.from_text(text2)), + matches=MultipleTextsAlignment.get_matches( + tokenized_text_1.get_words(), + tokenized_text_2.get_words() + ) + ) + + # def get_character_alignment(self) -> + + @staticmethod + def get_matches( + words1: list[Substring], + words2: list[Substring], + diff_only: bool = False, + improved_matching: bool = True, + ) -> list[WordLevelMatch]: + """ + Finds matching words (excluding punctuation) in two word lists. If `diff_only`, + returns only non-equal matches: deletions, additions or changes. + + With `improved_matching=True`, performs postprocessing after `difflib.SequenceMatcher` + to split of join some matches. + """ + # get operations (delete, insert, replace, equal) + difflib_opcodes: list[tuple[str, int, int, int, int]] = difflib.SequenceMatcher( + None, + [t.text for t in words1], + [t.text for t in words2], + autojunk=False + ).get_opcodes() + + ops: list[WordLevelMatch] = [ + WordLevelMatch(start1, end1, start2, end2, is_equal=(op == 'equal')) + for op, start1, end1, start2, end2 in difflib_opcodes + ] - # now we have a list of Match-es between words1 and words2 + # now we have a list of Match-es between words1 and words2 + + if improved_matching: + for _ in range(10): + # improvements over plain SequenceMatcher + ops, was_change1 = MultipleTextsAlignment._maybe_split_replace_ops(words1, words2, ops) + ops, was_change2 = MultipleTextsAlignment._maybe_join_subsequent_ops(words1, words2, ops) + + if not was_change1 and not was_change2: + break + + if diff_only: + # consider only non-equal matches + ops = [op for op in ops if not op.is_equal] + + return ops + + def get_correction_suggestions(self) -> list[CorrectionSuggestion]: + """ + Returns a list of suggestions to replace, delete or insert something in the `text1`, + based on the difference between both texts. Thus, this function is not symmetric, + since output suggestions contain positions in the `text1`. Punctuation is not compared, + which means that the punctiation from `text2` is never used. + """ + words1 = self.text1.get_words() + words2 = self.text2.get_words() + diffs = [op for op in self.matches if not op.is_equal] + + # get the positions in the original text, convert to correction suggestions + suggestions: list[CorrectionSuggestion] = [] + + for diff in diffs: + # position + if diff.start1 != diff.end1: + text1_start_pos = words1[diff.start1].start + text1_end_pos = words1[diff.end1 - 1].stop + else: + # suggestion to add + if diff.end1 > 0: + add_mode = 'append' + pos = words1[diff.end1 - 1].stop + else: + add_mode = 'prepend' + pos = words1[diff.end1].start + text1_start_pos = pos + text1_end_pos = pos + + # suggestion + if diff.start2 == diff.end2: + suggestion = '' + else: + text2_start_idx = words2[diff.start2].start + text2_end_idx = words2[diff.end2 - 1].stop + suggestion = self.text2.text[text2_start_idx:text2_end_idx] + if diff.start1 == diff.end1: + # suggestion to add + if add_mode == 'append': + suggestion = ' ' + suggestion + elif add_mode == 'prepend': + suggestion = suggestion + ' ' + + suggestions.append(CorrectionSuggestion(text1_start_pos, text1_end_pos, suggestion)) - for _ in range(10): - # we split some "replace" ops into two ops, such as - # replace('aaaa bbb ccc', 'aaa') -> replace('aaaa', 'aaa') + delete('bbb ccc') - new_ops: list[Match] = [] + return suggestions + + @staticmethod + def _string_match_score(word1: str, word2: str) -> float: + """ + How similar are two strings (character-wise)? + """ + return difflib.SequenceMatcher(None, word1, word2).ratio() + + @staticmethod + def _maybe_split_replace_ops( + words1: list[Substring], + words2: list[Substring], + ops: list[WordLevelMatch], + ) -> tuple[list[WordLevelMatch], bool]: + """ + We try to split some "replace" ops into two ops, such as + replace('aaaa bbb ccc', 'aaa') -> replace('aaaa', 'aaa') + delete('bbb ccc') + + Returns + - a new ops list + - flag that is True if any changes were made + """ + new_ops: list[WordLevelMatch] = [] for match in ops: start1, end1, start2, end2 = match.start1, match.end1, match.start2, match.end2 if not match.is_replace: new_ops.append(match) else: - if words_close_match(words1[start1].text, words2[start2].text): - new_ops.append(Match(start1, start1 + 1, start2, start2 + 1, is_equal=False)) + if MultipleTextsAlignment._string_match_score(words1[start1].text, words2[start2].text) > 0.5: + new_ops.append(WordLevelMatch(start1, start1 + 1, start2, start2 + 1, is_equal=False)) if end1 > start1 + 1 or end2 > start2 + 1: - new_ops.append(Match(start1 + 1, end1, start2 + 1, end2, is_equal=False)) - elif words_close_match(words1[end1 - 1].text, words2[end2 - 1].text): + new_ops.append(WordLevelMatch(start1 + 1, end1, start2 + 1, end2, is_equal=False)) + elif MultipleTextsAlignment._string_match_score(words1[end1 - 1].text, words2[end2 - 1].text) > 0.5: if end1 - 1 > start1 or end2 - 1 > start2: - new_ops.append(Match(start1, end1 - 1, start2, end2 - 1, is_equal=False)) - new_ops.append(Match(end1 - 1, end1, end2 - 1, end2, is_equal=False)) + new_ops.append(WordLevelMatch(start1, end1 - 1, start2, end2 - 1, is_equal=False)) + new_ops.append(WordLevelMatch(end1 - 1, end1, end2 - 1, end2, is_equal=False)) else: new_ops.append(match) - orig_ops = ops - ops = new_ops - if ops == orig_ops: - break - - # consider only non-equal matches - diffs = [op for op in ops if not op.is_equal] - - # get the positions in the original text, convert to correction suggestions - suggestions: list[CorrectionSuggestion] = [] - - for diff in diffs: - # position - if diff.start1 != diff.end1: - text1_start_pos = words1[diff.start1].start - text1_end_pos = words1[diff.end1 - 1].stop - else: - # suggestion to add - if diff.end1 > 0: - add_mode = 'append' - pos = words1[diff.end1 - 1].stop - else: - add_mode = 'prepend' - pos = words1[diff.end1].start - text1_start_pos = pos - text1_end_pos = pos - # suggestion - if diff.start2 == diff.end2: - suggestion = '' - else: - text2_start_idx = words2[diff.start2].start - text2_end_idx = words2[diff.end2 - 1].stop - suggestion = text2[text2_start_idx:text2_end_idx] - if diff.start1 == diff.end1: - # suggestion to add - if add_mode == 'append': - suggestion = ' ' + suggestion - elif add_mode == 'prepend': - suggestion = suggestion + ' ' + return new_ops, (ops != new_ops) + + @staticmethod + def _maybe_join_subsequent_ops( + words1: list[Substring], + words2: list[Substring], + ops: list[WordLevelMatch], + ) -> tuple[list[WordLevelMatch], bool]: + """ + We try to merge two subsequent ops, such as + delete('no', '') + replace('thing', 'nothing') -> replace('no thing', 'nothing') - suggestions.append(CorrectionSuggestion(text1_start_pos, text1_end_pos, suggestion)) + Returns + - a new ops list + - flag that is True if any changes were made + """ + new_ops: list[WordLevelMatch] = [] + i = 0 + while i < len(ops): + op = ops[i] + if i == len(ops) - 1: + # the last op, cannot merge with subsequent op + new_ops.append(op) + i += 1 + continue + next_op = ops[i + 1] + if op.end1 != next_op.start1 or op.end2 != next_op.start2: + # ops are not close to each other + new_ops.append(op) + i += 1 + continue + if op.is_equal and next_op.is_equal: + # we usually shouldn't have two `.is_equal` ops in a row, but just in case + new_ops.append(op) + i += 1 + continue + op_words1 = ' '.join(x.text for x in words1[op.start1:op.end1]) + op_words2 = ' '.join(x.text for x in words2[op.start2:op.end2]) + next_op_words1 = ' '.join(x.text for x in words1[next_op.start1:next_op.end1]) + next_op_words2 = ' '.join(x.text for x in words2[next_op.start2:next_op.end2]) + + match_score = MultipleTextsAlignment._string_match_score(op_words1, op_words2) + next_match_score = MultipleTextsAlignment._string_match_score(next_op_words1, next_op_words2) + joint_match_score = MultipleTextsAlignment._string_match_score( + op_words1 + ' ' + next_op_words1, + op_words2 + ' ' + next_op_words2 + ) + + if joint_match_score > max(match_score, next_match_score): + # merging ops + new_ops.append(WordLevelMatch(op.start1, next_op.end1, op.start2, next_op.end2, is_equal=False)) + i += 2 # skipping the next op, since we've already merged it + else: + new_ops.append(op) + i += 1 + + return new_ops, (ops != new_ops) - return suggestions +@dataclass +class CorrectionSuggestion: + """ + A suggestion to correct some text in some place, by replacing `text[start_pos:end_pos]` + with `suggestion`. If `start_pos == end_pos`, this is a suggestion to add a text in + `start_pos` position. + """ + start_pos: int + end_pos: int + suggestion: str def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSuggestion]) -> str: """ @@ -200,6 +434,9 @@ def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSugg >>> 'она советовала нам {отнестись|отнести} {+и} {посему|спасену} предмету к одному {почтенному|почтиному} мужу' ``` """ + if len(suggestions) == 0: + return text + result = '' for i, suggestion in enumerate(suggestions): start = suggestion.start_pos @@ -219,7 +456,7 @@ def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSugg visualized_suggestion = visualized_suggestion + ' ' elif len(hypothesis2) == 0: # suggestion to remove - visualized_suggestion = '{+' + hypothesis1 + '}' + visualized_suggestion = '{' + hypothesis1 + '}' else: # suggestion to correct visualized_suggestion = '{' + hypothesis1 + '|' + hypothesis2 + '}' From 664b0eeb712f05c1b1cd68c3f3cb0cef560b2b82 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Mon, 25 Nov 2024 17:04:16 +0300 Subject: [PATCH 09/24] uncertainty code updates --- asr/asr.py | 110 ++++++++++++++++++++++++++++--------------- asr/comparison.py | 4 +- server_ru.py | 7 ++- tests/test_asr_en.py | 16 ++----- tests/test_asr_ru.py | 16 ++----- 5 files changed, 89 insertions(+), 64 deletions(-) diff --git a/asr/asr.py b/asr/asr.py index cf7c867..13862ae 100644 --- a/asr/asr.py +++ b/asr/asr.py @@ -1,4 +1,5 @@ import copy +from dataclasses import dataclass import gc import logging from typing import List, Optional, Tuple, Union @@ -8,6 +9,7 @@ from tqdm import tqdm import torch from transformers import pipeline, Pipeline +from scipy.signal import resample_poly from utils.utils import time_to_str from wav_io.wav_io import TARGET_SAMPLING_FREQUENCY @@ -577,7 +579,7 @@ def is_speech(sound: np.ndarray, classifier: Pipeline) -> bool: return contains_speech -def recognize_sounds(sounds: List[np.ndarray], recognizer: Pipeline) -> List[str]: +def recognize_sounds(sounds: List[np.ndarray], recognizer: Pipeline, stretch: tuple[int, int] | None = None) -> List[str]: """ Arguments: - mono_sound: a list of 1D waveforms with rate 16_000 (equals wav_io.TARGET_SAMPLING_FREQUENCY) @@ -599,12 +601,22 @@ def recognize_sounds(sounds: List[np.ndarray], recognizer: Pipeline) -> List[str raise ValueError(err_msg) all_transcriptions = [] - for cur_sound in sounds: # tqdm(sounds): + for cur_sound in tqdm(sounds): all_transcriptions.append(recognizer(cur_sound)['text']) gc.collect() torch.cuda.empty_cache() return [remove_oscillatory_hallucinations(it) for it in all_transcriptions] +@dataclass +class TranscribedSegment: + """ + A transcribed segment. See `.transcribe()` function for details. + """ + start: float + end: float + transcription: str + transcription_from_segmenter: str | None = None + transcription_stretched: str | None = None def transcribe( mono_sound: np.ndarray, @@ -612,8 +624,9 @@ def transcribe( voice_activity_detector: Pipeline, asr: Pipeline, min_segment_size: float, - max_segment_size: float -) -> List[Tuple[float, float, str, str]]: + max_segment_size: float, + stretch: tuple[int, int] | None = None, +) -> List[TranscribedSegment]: """ Transcribes a (possibly long) audio as follows: @@ -636,9 +649,15 @@ def transcribe( `initialize_model_for_speech_recognition` for details. - min_segment_size: a parameter for segment processing, see `segment_sound` for details. - max_segment_size: a parameter for segment processing, see `segment_sound` for details. + - stretch: if specified, stretches each segment in `stretch[1]/stretch[0]` times and perform an + additional speech recognition with `asr` pipeline. The results are returned in + `.transcription_stretched` field of `TranscribedSegment`. - Output: a list of tuples (start_time, end_time, transcription_from_segmenter, transcription) - for all found utterances, can be empty. + Output: a list of `TranscribedSegment` for all found utterances, can be empty: + - `.transcription`: a transcription from `asr` Pipeline. + - `.transcription_from_segmenter`: a transcription from `segmenter` Pipeline. + - `.transcription_stretched`: a transcription of stretched segment from `asr` Pipeline + (if `stretch` agument is provided) Example: ``` @@ -648,39 +667,46 @@ def transcribe( waveform = load_sound('tests/testdata/mono_sound.wav') segmenter = initialize_model_for_speech_segmentation() vad = initialize_model_for_speech_classification() - asr = initialize_model_for_speech_recognition('ru', 'openai/whisper-tiny') - results = transcribe(waveform, segmenter, vad, asr, min_segment_size=1, max_segment_size=5) + asr = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3') + results = transcribe(waveform, segmenter, vad, asr, min_segment_size=1, max_segment_size=5, stretch=(3, 4)) print(results) >>> [ - ( - 0.0, - 4.18, - 'она советовала нам отнестись посему предмету к одному почтенному мужу', - 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.' + TranscribedSegment( + start=0.0, + end=4.18, + transcription='она советовала нам отнестись посему предмету к одному почтенному мужу', + transcription_from_segmenter='Она советовала нам отнестись по всему предмету к одному почтенному мужу.', + transcription_stretched='Она советовала нам отнестись по всему предмету к одному почтенному мужу.' ), - ( - 4.18, - 6.8100000000000005, - 'бывшему другам ивану переселые годы', - 'Большому другому и вану переселший годы.' + TranscribedSegment( + start=4.18, + end=6.8100000000000005, + transcription='бывшему другам ивану переселые годы', + transcription_from_segmenter='бывшему другом Ивану Петровичу.', + transcription_stretched='Бывшему другом Ивану Петровичу.' ), - ( - 6.8100000000000005, - 11.28, - 'счастливые дни как вешние воды промчались они', - 'счастливые дни, как вешные воды, промчались они.' + TranscribedSegment( + start=6.8100000000000005, + end=11.28, + transcription='счастливые дни как вешние воды промчались они', + transcription_from_segmenter='Счастливые дни, как вешние воды, промчались они.', + transcription_stretched='Счастливые дни, как вешние воды, промчались они.' ) ] - from asr.comparison import compare, visualize_correction_suggestions + from asr.comparison import MultipleTextsAlignment, visualize_correction_suggestions - for start, end, text_from_segmenter, text in results: - print(visualize_correction_suggestions(text, compare(text, text_from_segmenter))) + for result in results: + suggestions = MultipleTextsAlignment.from_strings( + result.transcription, + result.transcription_stretched + ).get_correction_suggestions() + print(visualize_correction_suggestions(result.transcription, suggestions)) - >>> Она советовала нам {отнести|отнестись} {и спасену|посему} предмету к одному {почтиному|почтенному}{+} мужу. - {Большому другому и вану переселший|бывшему другам ивану переселые} годы. - счастливые дни, как {вешные|вешние}{+} воды, промчались они. + >>> она советовала нам отнестись {посему|по всему} предмету к одному почтенному мужу + бывшему {другам|другом} ивану {переселые годы|Петровичу} + счастливые дни как вешние воды промчались они ``` TODO when calling `voice_activity_detector` and `asr`, process all segments at once as @@ -735,14 +761,22 @@ def transcribe( return [] recognized_transcriptions = recognize_sounds( sounds=sounds_with_speech, - recognizer=asr + recognizer=asr, ) - del sounds_with_speech - results = list(filter( - lambda it2: len(it2[2]) > 0, - map( - lambda it: (it[0][0], it[0][1], it[0][2], it[1].strip()), - zip(segments_with_speech, recognized_transcriptions) + results = [ + TranscribedSegment(start, end, transcription_from_segmenter.strip(), transcription.strip()) + for (start, end, transcription_from_segmenter), transcription + in zip(segments_with_speech, recognized_transcriptions) + if len(transcription.strip()) > 0 + ] + if stretch is not None: + transcriptions_stretched = recognize_sounds( + sounds=[ + resample_poly(sound, up=stretch[0], down=stretch[1]) + for sound in sounds_with_speech + ], + recognizer=asr, ) - )) - return results + for result, t in zip(results, transcriptions_stretched): + result.transcription_stretched = t + return results \ No newline at end of file diff --git a/asr/comparison.py b/asr/comparison.py index e82bf92..b7cee6f 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -391,8 +391,8 @@ def _maybe_join_subsequent_ops( match_score = MultipleTextsAlignment._string_match_score(op_words1, op_words2) next_match_score = MultipleTextsAlignment._string_match_score(next_op_words1, next_op_words2) joint_match_score = MultipleTextsAlignment._string_match_score( - op_words1 + ' ' + next_op_words1, - op_words2 + ' ' + next_op_words2 + (op_words1 + ' ' + next_op_words1).strip(), + (op_words2 + ' ' + next_op_words2).strip() ) if joint_match_score > max(match_score, next_match_score): diff --git a/server_ru.py b/server_ru.py index 4e51ca0..5a65adb 100644 --- a/server_ru.py +++ b/server_ru.py @@ -147,10 +147,13 @@ async def transcribe(): async def create_result_file(input_sound, segmenter, vad, asr, task_id): - texts_with_timestamps = transcribe_speech(input_sound, segmenter, vad, asr, MIN_FRAME_SIZE, MAX_FRAME_SIZE) + segment_transcriptions = transcribe_speech(input_sound, segmenter, vad, asr, MIN_FRAME_SIZE, MAX_FRAME_SIZE) output_filename = task_id + '.docx' doc = Document() - for start_time, end_time, text_from_segmenter, text_final in texts_with_timestamps: + for segment_transcription in segment_transcriptions: + start_time = segment_transcription.start + end_time = segment_transcription.end + text_final = segment_transcription.transcription line = f'{start_time:.2f} - {end_time:.2f} - {text_final}' doc.add_paragraph(line) doc.add_paragraph('') diff --git a/tests/test_asr_en.py b/tests/test_asr_en.py index f143807..5263a04 100644 --- a/tests/test_asr_en.py +++ b/tests/test_asr_en.py @@ -77,18 +77,12 @@ def test_recognize_pos01(self): max_segment_size=5 ) true_words = ['neural', 'networks', 'are', 'good'] - self.assertIsInstance(res, list) + predicted_text = ' '.join([r.transcription for r in res]) self.assertEqual(len(res), 1) - self.assertIsInstance(res[0], tuple) - self.assertEqual(len(res[0]), 4) - self.assertIsInstance(res[0][0], float) - self.assertIsInstance(res[0][1], float) - self.assertIsInstance(res[0][2], str) - self.assertIsInstance(res[0][3], str) - self.assertLessEqual(0.0, res[0][0]) - self.assertLess(res[0][0], res[0][1]) - self.assertLessEqual(res[0][1], self.sound.shape[0] / TARGET_SAMPLING_FREQUENCY) - predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(res[0][3].lower()))) + self.assertLessEqual(0.0, res[0].start) + self.assertLess(res[0].start, res[0].end) + self.assertLessEqual(res[0].end, self.sound.shape[0] / TARGET_SAMPLING_FREQUENCY) + predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(predicted_text))) self.assertEqual(predicted_words, true_words) def test_recognize_pos02(self): diff --git a/tests/test_asr_ru.py b/tests/test_asr_ru.py index ddd51b7..64e2fb0 100644 --- a/tests/test_asr_ru.py +++ b/tests/test_asr_ru.py @@ -77,18 +77,12 @@ def test_recognize_pos01(self): max_segment_size=5 ) true_words = ['нейронные', 'сети', 'это', 'хорошо'] - self.assertIsInstance(res, list) + predicted_text = ' '.join([r.transcription for r in res]) self.assertEqual(len(res), 1) - self.assertIsInstance(res[0], tuple) - self.assertEqual(len(res[0]), 4) - self.assertIsInstance(res[0][0], float) - self.assertIsInstance(res[0][1], float) - self.assertIsInstance(res[0][2], str) - self.assertIsInstance(res[0][3], str) - self.assertLessEqual(0.0, res[0][0]) - self.assertLess(res[0][0], res[0][1]) - self.assertLessEqual(res[0][1], self.sound.shape[0] / TARGET_SAMPLING_FREQUENCY) - predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(res[0][3].lower()))) + self.assertLessEqual(0.0, res[0].start) + self.assertLess(res[0].start, res[0].end) + self.assertLessEqual(res[0].end, self.sound.shape[0] / TARGET_SAMPLING_FREQUENCY) + predicted_words = list(filter(lambda it: it.isalnum(), wordpunct_tokenize(predicted_text))) self.assertEqual(predicted_words, true_words) def test_recognize_pos02(self): From 8a900a6694fb43c48e2243385468bcc25642446e Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Mon, 25 Nov 2024 17:10:00 +0300 Subject: [PATCH 10/24] fixes --- asr/asr.py | 2 +- tests/test_asr_en.py | 2 +- tests/test_asr_ru.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/asr/asr.py b/asr/asr.py index 13862ae..46a8fdf 100644 --- a/asr/asr.py +++ b/asr/asr.py @@ -764,7 +764,7 @@ def transcribe( recognizer=asr, ) results = [ - TranscribedSegment(start, end, transcription_from_segmenter.strip(), transcription.strip()) + TranscribedSegment(start, end, transcription.strip(), transcription_from_segmenter.strip()) for (start, end, transcription_from_segmenter), transcription in zip(segments_with_speech, recognized_transcriptions) if len(transcription.strip()) > 0 diff --git a/tests/test_asr_en.py b/tests/test_asr_en.py index 5263a04..3d0f406 100644 --- a/tests/test_asr_en.py +++ b/tests/test_asr_en.py @@ -77,7 +77,7 @@ def test_recognize_pos01(self): max_segment_size=5 ) true_words = ['neural', 'networks', 'are', 'good'] - predicted_text = ' '.join([r.transcription for r in res]) + predicted_text = ' '.join([r.transcription for r in res]).lower() self.assertEqual(len(res), 1) self.assertLessEqual(0.0, res[0].start) self.assertLess(res[0].start, res[0].end) diff --git a/tests/test_asr_ru.py b/tests/test_asr_ru.py index 64e2fb0..5d62666 100644 --- a/tests/test_asr_ru.py +++ b/tests/test_asr_ru.py @@ -77,7 +77,7 @@ def test_recognize_pos01(self): max_segment_size=5 ) true_words = ['нейронные', 'сети', 'это', 'хорошо'] - predicted_text = ' '.join([r.transcription for r in res]) + predicted_text = ' '.join([r.transcription for r in res]).lower() self.assertEqual(len(res), 1) self.assertLessEqual(0.0, res[0].start) self.assertLess(res[0].start, res[0].end) From 38ee08f66a0966118aa07d7e31acf2848f0c58e5 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Wed, 27 Nov 2024 08:56:49 +0300 Subject: [PATCH 11/24] wer calculation --- asr/comparison.py | 198 +++++++++++++++++++++++++++++++--------------- 1 file changed, 133 insertions(+), 65 deletions(-) diff --git a/asr/comparison.py b/asr/comparison.py index b7cee6f..d765645 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from dataclasses import dataclass import difflib import razdel @@ -109,6 +110,7 @@ def is_insert(self) -> bool: def is_delete(self) -> bool: return self.len2 == 0 + @dataclass class MultipleTextsAlignment: """ @@ -199,6 +201,21 @@ class MultipleTextsAlignment: >>> '{Aaaa} aa, {bb-bb|bbbb} {+cc cc}' ``` + + NOTE: while this class keeps a list of `WordLevelMatch`, and each match `m` may be one of + `m.is_equal`, `m.is_delete`, `m.is_insert` or `m.is_replace`, they do not directly correspond + one-to-one to "delete", "insert" and "replace" operations in Word Error Rate (WER) metric. + Example: + + ``` + print(MultipleTextsAlignment.from_strings('a b c', 'd e').matches) + + >>> [WordLevelMatch(start1=0, end1=3, start2=0, end2=2, is_equal=False)] + ``` + + We can see a single "replace" operation from 3 words to 2 words. However, in WER metric this + will be considered as two "replace" and one "delete" operation. To calculate WER correctly, + use `.wer` property. """ text1: TokenizedText text2: TokenizedText @@ -215,7 +232,40 @@ def from_strings(cls, text1: str, text2: str) -> MultipleTextsAlignment: ) ) - # def get_character_alignment(self) -> + def wer(self, max_insertions: int | None = 4) -> float: + """ + Calculates WER. `max_insertions` allows to make WER more robust by not penalizing + too much insertions in a row (usually an oscillatory hallucinations of ASR model). + + TODO switch to n unique insertions + """ + _max_insertions = float('inf') if max_insertions is None else max_insertions + + n_equal = sum([m.len1 for m in self.matches if m.is_equal]) + n_deletions = sum([m.len1 for m in self.matches if m.is_delete]) + n_insertions = sum([min(m.len2, _max_insertions) for m in self.matches if m.is_insert]) + n_replacements = 0 + + # replace operations contrubute to n_deletions and n_insertions if len1 != len2 + for match in self.matches: + if match .is_replace: + if match.len1 > match.len2: + n_replacements += match.len2 + n_deletions += match.len1 - match.len2 + elif match.len1 < match.len2: + n_replacements += match.len1 + n_insertions += min(match.len2 - match.len1, _max_insertions) + else: + n_replacements += match.len1 + + if max_insertions is None: + assert n_equal + n_deletions + n_replacements == len(self.text1.get_words()) + assert n_equal + n_insertions + n_replacements == len(self.text2.get_words()) + + return ( + (n_deletions + n_insertions + n_replacements) + / (n_equal + n_deletions + n_replacements) + ) @staticmethod def get_matches( @@ -260,54 +310,6 @@ def get_matches( ops = [op for op in ops if not op.is_equal] return ops - - def get_correction_suggestions(self) -> list[CorrectionSuggestion]: - """ - Returns a list of suggestions to replace, delete or insert something in the `text1`, - based on the difference between both texts. Thus, this function is not symmetric, - since output suggestions contain positions in the `text1`. Punctuation is not compared, - which means that the punctiation from `text2` is never used. - """ - words1 = self.text1.get_words() - words2 = self.text2.get_words() - diffs = [op for op in self.matches if not op.is_equal] - - # get the positions in the original text, convert to correction suggestions - suggestions: list[CorrectionSuggestion] = [] - - for diff in diffs: - # position - if diff.start1 != diff.end1: - text1_start_pos = words1[diff.start1].start - text1_end_pos = words1[diff.end1 - 1].stop - else: - # suggestion to add - if diff.end1 > 0: - add_mode = 'append' - pos = words1[diff.end1 - 1].stop - else: - add_mode = 'prepend' - pos = words1[diff.end1].start - text1_start_pos = pos - text1_end_pos = pos - - # suggestion - if diff.start2 == diff.end2: - suggestion = '' - else: - text2_start_idx = words2[diff.start2].start - text2_end_idx = words2[diff.end2 - 1].stop - suggestion = self.text2.text[text2_start_idx:text2_end_idx] - if diff.start1 == diff.end1: - # suggestion to add - if add_mode == 'append': - suggestion = ' ' + suggestion - elif add_mode == 'prepend': - suggestion = suggestion + ' ' - - suggestions.append(CorrectionSuggestion(text1_start_pos, text1_end_pos, suggestion)) - - return suggestions @staticmethod def _string_match_score(word1: str, word2: str) -> float: @@ -405,18 +407,28 @@ def _maybe_join_subsequent_ops( return new_ops, (ops != new_ops) -@dataclass -class CorrectionSuggestion: +def filter_correction_suggestions(alignment: MultipleTextsAlignment) -> list[WordLevelMatch]: """ - A suggestion to correct some text in some place, by replacing `text[start_pos:end_pos]` - with `suggestion`. If `start_pos == end_pos`, this is a suggestion to add a text in - `start_pos` position. + Arguments: + - alignment: a `MultipleTextsAlignment` between base speech recognition predictions and + additional predictions from another model. + + Outputs: + - list of all non-equal matches, filtered by several heuristics. + + The output is treated as suggestions to replace, delete or insert something in the `text1`, + based on the difference between words in both texts. Punctuation is not compared, since + `MultipleTextsAlignment` ignores punctuation. """ - start_pos: int - end_pos: int - suggestion: str + diffs = [op for op in alignment.matches if not op.is_equal] + + return diffs -def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSuggestion]) -> str: + +def visualize_correction_suggestions( + alignment: MultipleTextsAlignment, + diffs: list[WordLevelMatch], +) -> str: """ Visualize suggestions in {brackets}. - {aaa|bbb} - suggest to replace aaa to bbb @@ -428,24 +440,80 @@ def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSugg ``` text1 = 'она советовала нам отнестись посему предмету к одному почтенному мужу' text2 = 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.' - suggestions = compare(text1, text2) - print(visualize_correction_suggestions(text1, suggestions)) + alignment = MultipleTextsAlignment.from_strings(text1, text2) + suggestions = filter_correction_suggestions(alignment) + print(visualize_correction_suggestions(alignment, suggestions)) >>> 'она советовала нам {отнестись|отнести} {+и} {посему|спасену} предмету к одному {почтенному|почтиному} мужу' ``` """ - if len(suggestions) == 0: - return text + text1 = alignment.text1.text + words1 = alignment.text1.get_words() + + text2 = alignment.text2.text + words2 = alignment.text2.get_words() + + if len(diffs) == 0: + return text1 + # determining character positions in the original text + + @dataclass + class CorrectionSuggestion: + """ + A suggestion to correct some text in some place, by replacing `text[start_pos:end_pos]` + with `suggestion`. If `start_pos == end_pos`, this is a suggestion to add a text in + `start_pos` position. + """ + start_pos: int + end_pos: int + suggestion: str + + suggestions: list[CorrectionSuggestion] = [] + + for diff in diffs: + # calculate position + if diff.start1 != diff.end1: + text1_start_pos = words1[diff.start1].start + text1_end_pos = words1[diff.end1 - 1].stop + else: + # suggestion to add + if diff.end1 > 0: + add_mode = 'append' + pos = words1[diff.end1 - 1].stop + else: + add_mode = 'prepend' + pos = words1[diff.end1].start + text1_start_pos = pos + text1_end_pos = pos + + # suggestion + if diff.start2 == diff.end2: + suggestion = '' + else: + text2_start_idx = words2[diff.start2].start + text2_end_idx = words2[diff.end2 - 1].stop + suggestion = text2[text2_start_idx:text2_end_idx] + if diff.start1 == diff.end1: + # suggestion to add + if add_mode == 'append': + suggestion = ' ' + suggestion + elif add_mode == 'prepend': + suggestion = suggestion + ' ' + + suggestions.append(CorrectionSuggestion(text1_start_pos, text1_end_pos, suggestion)) + + # render the result + result = '' for i, suggestion in enumerate(suggestions): start = suggestion.start_pos end = suggestion.end_pos prev_end = suggestions[i - 1].end_pos if i > 0 else None - result += text[prev_end:start] + result += text1[prev_end:start] - hypothesis1 = text[start:end] + hypothesis1 = text1[start:end] hypothesis2 = suggestion.suggestion if len(hypothesis1) == 0: # suggestion to add @@ -463,6 +531,6 @@ def visualize_correction_suggestions(text: str, suggestions: list[CorrectionSugg result += visualized_suggestion - result += text[end:] + result += text1[end:] return result \ No newline at end of file From e156fa75a856f6b06797f2055142d44d9248b58b Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Wed, 27 Nov 2024 12:24:04 +0300 Subject: [PATCH 12/24] whisper language fix --- asr/asr.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/asr/asr.py b/asr/asr.py index 46a8fdf..caf969c 100644 --- a/asr/asr.py +++ b/asr/asr.py @@ -289,11 +289,19 @@ def initialize_model_for_speech_recognition(language: str = 'ru', model_info: Op else: model_name = 'openai/whisper-large-v3' try: + pipeline_kwargs = {} + if 'whisper' in model_name.lower(): + if language == 'ru': + pipeline_kwargs['generate_kwargs'] = {'language': '<|ru|>', 'task': 'transcribe'} + elif language == 'en': + pipeline_kwargs['generate_kwargs'] = {'language': '<|en|>', 'task': 'transcribe'} + if torch.cuda.is_available(): recognizer = pipeline( 'automatic-speech-recognition', model=model_name, chunk_length_s=20, stride_length_s=(4, 2), - device='cuda:0', model_kwargs={'attn_implementation': 'sdpa'}, torch_dtype=torch.float16 + device='cuda:0', model_kwargs={'attn_implementation': 'sdpa'}, torch_dtype=torch.float16, + **pipeline_kwargs ) else: recognizer = pipeline( From c78886ba8defbd14f60ee2097ae820e633b0308b Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Thu, 28 Nov 2024 16:57:34 +0300 Subject: [PATCH 13/24] uncertainty code --- asr/comparison.py | 393 +++++++++++++++++++++++++++++++--------------- 1 file changed, 266 insertions(+), 127 deletions(-) diff --git a/asr/comparison.py b/asr/comparison.py index d765645..c41b3eb 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -3,7 +3,11 @@ import copy from dataclasses import dataclass import difflib +from typing import Iterable, Literal +import numpy as np import razdel +from pymystem3 import Mystem +from tqdm.auto import tqdm @dataclass class Substring: @@ -56,7 +60,10 @@ def get_words(self) -> list[Substring]: return [t for t in self.tokens if not t.is_punct] @classmethod - def from_text(cls, text: str) -> TokenizedText: + def from_text(cls, text: str, dash_as_separator: bool = True) -> TokenizedText: + orig_text = text + if dash_as_separator: + text = text.replace('-', ' ') tokens = [ Substring( start=t.start, @@ -66,7 +73,7 @@ def from_text(cls, text: str) -> TokenizedText: ) for t in razdel.tokenize(text) ] - return TokenizedText(text=text, tokens=tokens) + return TokenizedText(text=orig_text, tokens=tokens) @dataclass class WordLevelMatch: @@ -85,6 +92,11 @@ class WordLevelMatch: end2: int is_equal: bool + char_start1: int | None = None + char_end1: int | None = None + char_start2: int | None = None + char_end2: int | None = None + def __post_init__(self): assert self.len1 > 0 or self.len2 > 0 if self.is_equal: @@ -228,11 +240,21 @@ def from_strings(cls, text1: str, text2: str) -> MultipleTextsAlignment: text2=(tokenized_text_2 := TokenizedText.from_text(text2)), matches=MultipleTextsAlignment.get_matches( tokenized_text_1.get_words(), - tokenized_text_2.get_words() + tokenized_text_2.get_words(), ) ) - def wer(self, max_insertions: int | None = 4) -> float: + def get_uncertainty_mask(self) -> np.ndarray: + is_certain = np.full(len(self.text1.get_words()), False) + for match in self.matches: + is_certain[match.start1:match.end1] = match.is_equal + return ~is_certain + + def wer( + self, + max_insertions: int | None = 4, + uncertainty_mask: np.ndarray = None, + ) -> dict: """ Calculates WER. `max_insertions` allows to make WER more robust by not penalizing too much insertions in a row (usually an oscillatory hallucinations of ASR model). @@ -240,6 +262,9 @@ def wer(self, max_insertions: int | None = 4) -> float: TODO switch to n unique insertions """ _max_insertions = float('inf') if max_insertions is None else max_insertions + + words1 = self.text1.get_words() + words2 = self.text2.get_words() n_equal = sum([m.len1 for m in self.matches if m.is_equal]) n_deletions = sum([m.len1 for m in self.matches if m.is_delete]) @@ -248,7 +273,7 @@ def wer(self, max_insertions: int | None = 4) -> float: # replace operations contrubute to n_deletions and n_insertions if len1 != len2 for match in self.matches: - if match .is_replace: + if match.is_replace: if match.len1 > match.len2: n_replacements += match.len2 n_deletions += match.len1 - match.len2 @@ -258,14 +283,43 @@ def wer(self, max_insertions: int | None = 4) -> float: else: n_replacements += match.len1 + assert n_equal + n_deletions + n_replacements == len(words1) if max_insertions is None: - assert n_equal + n_deletions + n_replacements == len(self.text1.get_words()) - assert n_equal + n_insertions + n_replacements == len(self.text2.get_words()) + assert n_equal + n_insertions + n_replacements == len(words2) + + results = {'wer': (n_deletions + n_insertions + n_replacements) / len(words1)} + + if uncertainty_mask is not None: + assert len(uncertainty_mask) == len(words2) + uncertainty_mask = uncertainty_mask.astype(bool) + + certain_n_correct = 0 + certain_n_incorrect = 0 + uncertain_n_correct = 0 + uncertain_n_incorrect = 0 + + for match in self.matches: + mask = uncertainty_mask[match.start2:match.end2] + if match.is_equal: + uncertain_n_correct += mask.sum() + certain_n_correct += (~mask).sum() + elif (match.is_insert or match.is_replace): + uncertain_n_incorrect += mask.sum() + certain_n_incorrect += (~mask).sum() + + if uncertainty_mask is not None: + results['certain_n_correct'] = certain_n_correct + results['certain_n_incorrect'] = certain_n_incorrect + results['uncertain_n_correct'] = uncertain_n_correct + results['uncertain_n_incorrect'] = uncertain_n_incorrect + results['certain_correctness_ratio'] = ( + certain_n_correct / (certain_n_correct + certain_n_incorrect) + ) + results['uncertain_correctness_ratio'] = ( + uncertain_n_correct / (uncertain_n_correct + uncertain_n_incorrect) + ) - return ( - (n_deletions + n_insertions + n_replacements) - / (n_equal + n_deletions + n_replacements) - ) + return results @staticmethod def get_matches( @@ -309,8 +363,28 @@ def get_matches( # consider only non-equal matches ops = [op for op in ops if not op.is_equal] + # set character positions for each WordLevelMatch + for op in ops: + if op.start1 != op.end1: + op.char_start1 = words1[op.start1].start + op.char_end1 = words1[op.end1 - 1].stop + else: + if op.end1 > 0: + op.char_start1 = op.char_end1 = words1[op.end1 - 1].stop + else: + op.char_start1 = op.char_end1 = words1[op.end1].start + + if op.start2 != op.end2: + op.char_start2 = words2[op.start2].start + op.char_end2 = words2[op.end2 - 1].stop + else: + if op.end2 > 0: + op.char_start2 = op.char_end2 = words2[op.end2 - 1].stop + else: + op.char_start2 = op.char_end2 = words2[op.end2].start + return ops - + @staticmethod def _string_match_score(word1: str, word2: str) -> float: """ @@ -406,131 +480,196 @@ def _maybe_join_subsequent_ops( i += 1 return new_ops, (ops != new_ops) - -def filter_correction_suggestions(alignment: MultipleTextsAlignment) -> list[WordLevelMatch]: - """ - Arguments: - - alignment: a `MultipleTextsAlignment` between base speech recognition predictions and - additional predictions from another model. - - Outputs: - - list of all non-equal matches, filtered by several heuristics. - The output is treated as suggestions to replace, delete or insert something in the `text1`, - based on the difference between words in both texts. Punctuation is not compared, since - `MultipleTextsAlignment` ignores punctuation. - """ - diffs = [op for op in alignment.matches if not op.is_equal] + def substitute( + self, + replace: Iterable[int] | None = None, + show_in_braces: Iterable[int] | None = None, + pref_first: Iterable[int] | None = None, + pref_second: Iterable[int] | None = None, + ) -> str: + """ + This function can insert fragments from the second text to the first text, + based on matches. + + Explanation. Let we have a `MultipleTextsAlignment` with a single non-equal match + (difference): + + ``` + text1 = "aa bb! cc!" + text2 = "aa bbb cc" + al = MultipleTextsAlignment.from_strings(text1, text2) + print([m for m in al.matches if not m.is_equal]) + >>> [WordLevelMatch(start1=1, end1=2, start2=1, end2=2, is_equal=False, + char_start1=3, char_end1=5, char_start2=3, char_end2=6)] + ``` + + The difference `m = al.matches[1]` corresponds to a substring in both texts: + 1) A segment in the 1st test: `al.text1.text[m.char_start1:m.char_end1] == 'bb'` + 2) A segment in the 2nd text: `al.text2.text[m.char_start2:m.char_end2] == 'bbb'`. + + Based on this, we can cut out the segment from the 1st text, and replace it + with the segment from the 2nd text. This is exactly what does the `substitute` method. + The `replace` argument is a list of all differences to apply. + + ``` + print(al.substitute(replace=[1])) + >>> "aa bbb! cc!" + ``` + + The `show_in_braces` is also a list of differences. It does not replace text parts, but + visualize both variants in {braces}. + - {aaa|bbb} - suggest to replace aaa to bbb + - {aaa} - suggest to remove aaa + - {+aaa} - suggest to insert aaa (not present in `text1`) + + ``` + text1 = 'она советовала нам отнестись посему предмету к одному почтенному мужу' + text2 = 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.' + al = MultipleTextsAlignment.from_strings(text1, text2) + al.substitute(show_in_braces=range(len(al.matches))) + >>> 'она советовала нам {отнестись|отнести} {+и} {посему|спасену} предмету к одному {почтенному|почтиному} мужу' + ``` + """ + text1 = self.text1.text + text2 = self.text2.text - return diffs + replace = list(replace) if replace is not None else [] + show_in_braces = list(show_in_braces) if show_in_braces is not None else [] + pref_first = list(pref_first) if pref_first is not None else [] + pref_second = list(pref_second) if pref_second is not None else [] + # assert set(pref_first).intersection(set(pref_second)) == set() -def visualize_correction_suggestions( - alignment: MultipleTextsAlignment, - diffs: list[WordLevelMatch], -) -> str: - """ - Visualize suggestions in {brackets}. - - {aaa|bbb} - suggest to replace aaa to bbb - - {aaa} - suggest to remove aaa - - {+aaa} - suggest to insert aaa (not present in `text`) - - Example: - - ``` - text1 = 'она советовала нам отнестись посему предмету к одному почтенному мужу' - text2 = 'Она советовала нам отнести и спасену предмету к одному почтиному мужу.' - alignment = MultipleTextsAlignment.from_strings(text1, text2) - suggestions = filter_correction_suggestions(alignment) - print(visualize_correction_suggestions(alignment, suggestions)) + result = '' + text1_idx = 0 - >>> 'она советовала нам {отнестись|отнести} {+и} {посему|спасену} предмету к одному {почтенному|почтиному} мужу' - ``` - """ - text1 = alignment.text1.text - words1 = alignment.text1.get_words() + for op_idx, op in enumerate(self.matches): + if op.is_equal: + continue - text2 = alignment.text2.text - words2 = alignment.text2.get_words() + result += text1[text1_idx:op.char_start1] + text1_idx = op.char_start1 - if len(diffs) == 0: - return text1 - - # determining character positions in the original text + segment1 = text1[op.char_start1:op.char_end1] + segment2 = text2[op.char_start2:op.char_end2] - @dataclass - class CorrectionSuggestion: - """ - A suggestion to correct some text in some place, by replacing `text[start_pos:end_pos]` - with `suggestion`. If `start_pos == end_pos`, this is a suggestion to add a text in - `start_pos` position. - """ - start_pos: int - end_pos: int - suggestion: str - - suggestions: list[CorrectionSuggestion] = [] - - for diff in diffs: - # calculate position - if diff.start1 != diff.end1: - text1_start_pos = words1[diff.start1].start - text1_end_pos = words1[diff.end1 - 1].stop - else: - # suggestion to add - if diff.end1 > 0: - add_mode = 'append' - pos = words1[diff.end1 - 1].stop + if op_idx in replace: + fragment = segment2 + + elif op_idx in show_in_braces: + if len(segment1) == 0: + formatting = 'add' + elif len(segment2) == 0: + formatting = 'remove' + else: + formatting = 'correct' + + if op_idx in pref_first: + segment1 = '!' + segment1 + if op_idx in pref_second: + segment2 = '!' + segment2 + + if formatting == 'add': + fragment = '{+' + segment2.strip() + '}' + if text1[op.char_start1] == ' ': + fragment = ' ' + fragment + else: + fragment = fragment + ' ' + elif formatting == 'remove': + fragment = '{' + segment1 + '}' + else: + fragment = '{' + segment1 + '|' + segment2 + '}' + else: - add_mode = 'prepend' - pos = words1[diff.end1].start - text1_start_pos = pos - text1_end_pos = pos + fragment = segment1 + + result += fragment + text1_idx = op.char_end1 - # suggestion - if diff.start2 == diff.end2: - suggestion = '' - else: - text2_start_idx = words2[diff.start2].start - text2_end_idx = words2[diff.end2 - 1].stop - suggestion = text2[text2_start_idx:text2_end_idx] - if diff.start1 == diff.end1: - # suggestion to add - if add_mode == 'append': - suggestion = ' ' + suggestion - elif add_mode == 'prepend': - suggestion = suggestion + ' ' - - suggestions.append(CorrectionSuggestion(text1_start_pos, text1_end_pos, suggestion)) + result += text1[text1_idx:] + + return result + + +def _is_junk_word(word: str) -> bool: + return word in ['вот', 'ага', 'и', 'а', 'ну', 'это'] + +def _is_junk_word_sequence(text: str) -> bool: + return text in ['то есть', 'да то есть', 'это самое'] + +def _lemmatize(text: str) -> str: + return ''.join(Mystem().lemmatize(text)).strip() # here we need to join with '', not ' ' + +def _should_keep( + alignment: MultipleTextsAlignment, + diff: WordLevelMatch, + skip_word_form_change: bool, +) -> bool: + """ + A single diff variant of .filter_correction_suggestions(). + """ + words1: list[str] = [w.text for w in alignment.text1.get_words()[diff.start1:diff.end1]] + words2: list[str] = [w.text for w in alignment.text2.get_words()[diff.start2:diff.end2]] + + joined1 = ' '.join(words1).lower().replace('ё', 'е') + joined2 = ' '.join(words2).lower().replace('ё', 'е') - # render the result - - result = '' - for i, suggestion in enumerate(suggestions): - start = suggestion.start_pos - end = suggestion.end_pos - prev_end = suggestions[i - 1].end_pos if i > 0 else None - - result += text1[prev_end:start] - - hypothesis1 = text1[start:end] - hypothesis2 = suggestion.suggestion - if len(hypothesis1) == 0: - # suggestion to add - visualized_suggestion = '{+' + hypothesis2.strip() + '}' - if hypothesis2.startswith(' '): - visualized_suggestion = ' ' + visualized_suggestion - if hypothesis2.endswith(' '): - visualized_suggestion = visualized_suggestion + ' ' - elif len(hypothesis2) == 0: - # suggestion to remove - visualized_suggestion = '{' + hypothesis1 + '}' - else: - # suggestion to correct - visualized_suggestion = '{' + hypothesis1 + '|' + hypothesis2 + '}' - - result += visualized_suggestion + if all([_is_junk_word(w) for w in words1]) and all([_is_junk_word(w) for w in words2]): + # insertion, replacement or deletion of junk words + return False + + if ( + (len(joined1) == 0 or _is_junk_word_sequence(joined1)) + and (len(joined2) == 0 or _is_junk_word_sequence(joined2)) + ): + # insertion, replacement or deletion of junk words + return False + + if diff.is_replace: + if joined1 == joined2: + # the same text + return False + if skip_word_form_change and _lemmatize(joined1) == _lemmatize(joined2): + # different forms of the same words, skip according to `skip_word_form_change=True` + return False + + ru_letters = set('абвгдеёжзийклмнопрстуфхцчшщъыьэюя') + has_ru1 = ru_letters & set(joined1) != set() + has_ru2 = ru_letters & set(joined2) != set() + + if has_ru1 and not has_ru2: + # probably a transliteration or letters-to-digits conversion + return False + if has_ru2 and not has_ru1: + # probably a transliteration or letters-to-digits conversion + return False - result += text1[end:] + return True + +def filter_correction_suggestions( + alignment: MultipleTextsAlignment, + skip_word_form_change: bool = False +) -> list[int]: + """ + Arguments: + - alignment: a `MultipleTextsAlignment` between base speech recognition predictions and + additional predictions from another model. + - skip_word_form_change: whether to skips word form changes - return result \ No newline at end of file + Outputs: + - Indices all non-equal matches, filtered by several heuristics. This is treated as + suggestions to replace, delete or insert something in the `text1`, based on the + difference between words in both texts. Punctuation is not compared, since + `MultipleTextsAlignment` ignores punctuation. + + NOTE: currently is adapted for Ru language + """ + return [ + i for i, op in enumerate(tqdm(alignment.matches, desc='Filtering suggestions')) + if not op.is_equal and _should_keep( + alignment=alignment, + diff=op, + skip_word_form_change=skip_word_form_change + ) + ] \ No newline at end of file From 3bcc040699dbe82a857e146ba7cfa3c6682c1076 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Fri, 29 Nov 2024 09:13:55 +0300 Subject: [PATCH 14/24] formatting --- asr/asr.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/asr/asr.py b/asr/asr.py index caf969c..ced7a87 100644 --- a/asr/asr.py +++ b/asr/asr.py @@ -292,9 +292,17 @@ def initialize_model_for_speech_recognition(language: str = 'ru', model_info: Op pipeline_kwargs = {} if 'whisper' in model_name.lower(): if language == 'ru': - pipeline_kwargs['generate_kwargs'] = {'language': '<|ru|>', 'task': 'transcribe'} + pipeline_kwargs['generate_kwargs'] = { + 'language': '<|ru|>', + 'task': 'transcribe', + 'forced_decoder_ids': None + } elif language == 'en': - pipeline_kwargs['generate_kwargs'] = {'language': '<|en|>', 'task': 'transcribe'} + pipeline_kwargs['generate_kwargs'] = { + 'language': '<|en|>', + 'task': 'transcribe', + 'forced_decoder_ids': None + } if torch.cuda.is_available(): recognizer = pipeline( From 9091def516e9cfd5d5af5d148de9dbd518723b11 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Fri, 29 Nov 2024 10:07:34 +0300 Subject: [PATCH 15/24] requirements --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a9355c5..4801078 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,4 +15,5 @@ torchvision==0.18.1 tokenizers>=0.19.1 transformers>=4.41.2 webrtcvad>=2.0.10 -setuptools \ No newline at end of file +setuptools +pymystem3 \ No newline at end of file From 724a572bb1f02aea801b2435b1fde452579f188c Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sat, 30 Nov 2024 18:22:40 +0300 Subject: [PATCH 16/24] eval notebooks --- evaluation/Make dataset.ipynb | 200 +++++++ evaluation/bond005_jsons_summarize.ipynb | 561 +++++++++++++++++++ evaluation/get_baseline_results.py | 87 +++ evaluation/get_pisets_results.py | 56 ++ evaluation/my_pisets_results_summarize.ipynb | 492 ++++++++++++++++ evaluation/requirements.txt | 3 + evaluation/simple_eval.ipynb | 336 +++++++++++ 7 files changed, 1735 insertions(+) create mode 100644 evaluation/Make dataset.ipynb create mode 100644 evaluation/bond005_jsons_summarize.ipynb create mode 100644 evaluation/get_baseline_results.py create mode 100644 evaluation/get_pisets_results.py create mode 100644 evaluation/my_pisets_results_summarize.ipynb create mode 100644 evaluation/requirements.txt create mode 100644 evaluation/simple_eval.ipynb diff --git a/evaluation/Make dataset.ipynb b/evaluation/Make dataset.ipynb new file mode 100644 index 0000000..4f71b39 --- /dev/null +++ b/evaluation/Make dataset.ipynb @@ -0,0 +1,200 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "from itertools import combinations\n", + "from typing import Any\n", + "\n", + "import pandas as pd\n", + "import numpy as np\n", + "import torch\n", + "from transformers import (\n", + " AutoModelWithLMHead, AutoTokenizer, pipeline, Pipeline,\n", + " WhisperProcessor, WhisperForConditionalGeneration\n", + ")\n", + "import pysrt\n", + "from IPython.display import clear_output\n", + "import IPython.display\n", + "import librosa\n", + "\n", + "from asr.asr import (\n", + " initialize_model_for_speech_segmentation, initialize_model_for_speech_classification,\n", + " initialize_model_for_speech_recognition\n", + ")\n", + "from asr.comparison import MultipleTextsAlignment, filter_correction_suggestions" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/home/oleg/pisets_test_set\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/oleg/pisets/venv/lib/python3.12/site-packages/IPython/core/magics/osm.py:417: UserWarning: This is now an optional IPython functionality, setting dhist requires you to install the `pickleshare` library.\n", + " self.shell.db['dhist'] = compress_dhist(dhist)[-100:]\n" + ] + } + ], + "source": [ + "%cd ../pisets_test_set" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# import pysrt\n", + "\n", + "# for name in ['galore', 'tuberculosis']:\n", + "# truth = ' '.join([sub.text for sub in pysrt.open(name + '.srt')])\n", + "# with open(name + '.txt', 'w') as f:\n", + "# f.write(truth)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import librosa\n", + "from tqdm.auto import tqdm\n", + "from datasets import Dataset, Audio\n", + "\n", + "metainfo = {\n", + " 'zaliznyak': {'noise': 'reverberation, background speech', 'domain': 'philology'},\n", + " 'harvard': {'noise': 'background speech', 'domain': 'philosophy'},\n", + " 'savvateev': {'noise': 'reverberation, background speech', 'domain': 'mathematics'},\n", + " 'zhirinovsky': {'noise': 'reverberation, background speech', 'domain': 'politics'},\n", + " 'lankov': {'noise': 'reverberation, background speech', 'domain': 'history'},\n", + " 'kolodezev': {'noise': 'unknown (TODO)', 'domain': 'machine learning'},\n", + " 'tuberculosis': {'noise': 'unknown (TODO)', 'domain': 'medicine'},\n", + "}\n", + "\n", + "samples = []\n", + "\n", + "for name in tqdm(metainfo):\n", + " waveform, _ = librosa.load(f'{name}.wav', sr=16_000)\n", + " with open(f'{name}.txt') as f:\n", + " transcription = f.read()\n", + "\n", + " samples.append({\n", + " 'name': name,\n", + " 'audio': {'array': waveform, 'sampling_rate': 16_000},\n", + " 'transcription': transcription,\n", + " **metainfo[name],\n", + " })\n", + "\n", + "dataset = Dataset.from_list(samples) #.cast_column(\"audio\", Audio(decode=False))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# https://github.com/huggingface/datasets/issues/6703#issuecomment-1974761165\n", + "dataset.to_parquet('long_audio_youtube_dataset/data.parquet')" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "DatasetDict({\n", + " train: Dataset({\n", + " features: ['name', 'audio', 'transcription', 'noise', 'domain'],\n", + " num_rows: 7\n", + " })\n", + "})" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from datasets import load_dataset\n", + "dataset = load_dataset('long_audio_youtube_dataset')\n", + "dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'path': None,\n", + " 'array': array([0.00042725, 0.00112915, 0.00146484, ..., 0.00222778, 0.00164795,\n", + " 0.00262451]),\n", + " 'sampling_rate': 16000}" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from datasets import Audio\n", + "\n", + "dataset.cast_column(\"audio\", Audio(sampling_rate=16_000))['train'][0]['audio']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/evaluation/bond005_jsons_summarize.ipynb b/evaluation/bond005_jsons_summarize.ipynb new file mode 100644 index 0000000..6ecde4b --- /dev/null +++ b/evaluation/bond005_jsons_summarize.ipynb @@ -0,0 +1,561 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "\n", + "from asr.comparison import MultipleTextsAlignment" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
  bond005_wermy_wer
  whisperpodlodkadiffwhisperpodlodkadiff
audiosnr      
1_Зализняк_филологияnone9.712.5+2.99.812.5+2.7
01db50.736.4-14.241.834.3-7.5
02db9.111.7+2.79.311.5+2.2
03db9.120.1+11.09.313.1+3.8
04db9.512.2+2.79.712.2+2.5
05db9.311.9+2.69.511.9+2.4
2_Гарвард_философияnone2.02.7+0.72.02.7+0.7
01db2.43.1+0.72.43.1+0.7
02db3.44.4+1.03.44.4+1.0
03db2.23.7+1.52.23.7+1.5
04db2.73.6+0.92.73.6+0.9
05db2.83.3+0.52.63.3+0.7
3_Саватеев_математикаnone19.525.9+6.417.725.9+8.2
01db21.123.9+2.818.422.9+4.4
02db19.419.2-0.218.918.2-0.7
03db58.853.8-5.160.055.1-4.9
04db56.756.9+0.258.057.1-0.9
05db21.623.2+1.619.722.2+2.5
4_Жириновский_политикаnone6.88.6+1.76.88.6+1.7
01db33.331.1-2.233.431.1-2.3
02db14.78.3-6.410.38.3-2.0
03db14.98.3-6.510.58.3-2.1
04db17.518.7+1.217.518.7+1.2
05db14.38.0-6.39.98.0-1.9
5_Ланьков_историяnone8.610.3+1.68.610.3+1.7
01db13.011.4-1.613.111.4-1.8
02db30.533.7+3.230.333.9+3.7
03db15.028.8+13.815.021.4+6.4
04db10.311.2+1.010.311.3+1.0
05db9.910.1+0.210.010.1+0.2
\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# def get_longest_insertion(al: MultipleTextsAlignment) -> str:\n", + "# \"\"\"Get character length if the insertion with max words\n", + "# TODO fix, need to search in .is_replace ops also\n", + "# \"\"\"\n", + "# insertions = [m for m in al.matches if m.is_insert]\n", + "# if len(insertions) == 0:\n", + "# return ''\n", + "# max_insertion = max(insertions, key=lambda m: m.len2)\n", + "# inserted_words = al.text2.get_words()[max_insertion.start2:max_insertion.end2]\n", + "# return al.text2.text[inserted_words[0].start:inserted_words[-1].stop]\n", + "\n", + "def display_results(results: pd.DataFrame):\n", + " display(\n", + " results.style.format({\n", + " ('bond005_wer', 'whisper'): '{:.1f}',\n", + " ('bond005_wer', 'podlodka'): '{:.1f}',\n", + " ('bond005_wer', 'diff'): '{:+.1f}',\n", + " ('my_wer', 'whisper'): '{:.1f}',\n", + " ('my_wer', 'podlodka'): '{:.1f}',\n", + " ('my_wer', 'diff'): '{:+.1f}',\n", + " }).set_table_styles([\n", + " {\"selector\": \"td, th\", \"props\": [(\"border\", \"1px solid grey !important\")]},\n", + " ])\n", + " )\n", + "\n", + "base_dir = Path('../long_audio_ru')\n", + "\n", + "results = []\n", + "\n", + "names = ['1_Зализняк_филология', '2_Гарвард_философия', '3_Саватеев_математика', '4_Жириновский_политика', '5_Ланьков_история']\n", + "\n", + "for i in range(1, 6):\n", + " for snr in ['none', '01db', '02db', '03db', '04db', '05db']:\n", + "\n", + " # reading reports\n", + " dir = base_dir if snr == 'none' else base_dir / f'augmented/{snr}'\n", + " with open(f'{dir}/report_for_vad_pipeline_{i}.json') as f:\n", + " podlodka_preds_json = json.load(f)\n", + " with open(f'{dir}/report_for_vad_pipeline_{i}_multi.json') as f:\n", + " whisper_preds_json = json.load(f)\n", + "\n", + " # true transcription\n", + " truth = whisper_preds_json['true']\n", + " assert podlodka_preds_json['true'] == whisper_preds_json['true']\n", + "\n", + " # alignments\n", + " al_whisper = MultipleTextsAlignment.from_strings(truth, whisper_preds_json['pred'])\n", + " al_podlodka = MultipleTextsAlignment.from_strings(truth, podlodka_preds_json['pred'])\n", + " \n", + " # results\n", + " results.append({\n", + " 'audio': names[i - 1],\n", + " 'snr': snr,\n", + " ('bond005_wer', 'whisper'): 100 * float(whisper_preds_json['WER'][:-1]),\n", + " ('bond005_wer', 'podlodka'): 100 * float(podlodka_preds_json['WER'][:-1]),\n", + " ('my_wer', 'whisper'): 100 * al_whisper.wer()['wer'],\n", + " ('my_wer', 'podlodka'): 100 * al_podlodka.wer()['wer'],\n", + " # ('longest_insertion_len', 'whisper'): len(get_longest_insertion(al_whisper)),\n", + " # ('longest_insertion_len', 'podlodka'): len(get_longest_insertion(al_podlodka)),\n", + " })\n", + "\n", + "results = pd.DataFrame(results).set_index(['audio', 'snr'])\n", + "results.columns = pd.MultiIndex.from_tuples(results.columns)\n", + "results.index = pd.MultiIndex.from_tuples(results.index, names=['audio', 'snr'])\n", + "\n", + "results.insert(\n", + " loc=results.columns.get_loc(('bond005_wer', 'podlodka')) + 1,\n", + " column=('bond005_wer', 'diff'),\n", + " value=results[('bond005_wer', 'podlodka')] - results[('bond005_wer', 'whisper')],\n", + ")\n", + "results.insert(\n", + " loc=results.columns.get_loc(('my_wer', 'podlodka')) + 1,\n", + " column=('my_wer', 'diff'),\n", + " value=results[('my_wer', 'podlodka')] - results[('my_wer', 'whisper')],\n", + ")\n", + "\n", + "display_results(results)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
 bond005_wermy_wer
 whisperpodlodkadiffwhisperpodlodkadiff
audio      
1_Зализняк_филология16.217.5+1.314.915.9+1.0
2_Гарвард_философия2.63.5+0.92.53.5+0.9
3_Саватеев_математика32.933.8+1.032.133.5+1.4
4_Жириновский_политика16.913.8-3.114.713.8-0.9
5_Ланьков_история14.617.6+3.014.616.4+1.9
\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "display_results(results.groupby('audio').mean())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/evaluation/get_baseline_results.py b/evaluation/get_baseline_results.py new file mode 100644 index 0000000..f0c5588 --- /dev/null +++ b/evaluation/get_baseline_results.py @@ -0,0 +1,87 @@ +from pathlib import Path +from transformers import pipeline, Pipeline, WhisperProcessor, WhisperForConditionalGeneration +import pysrt +import librosa + +from asr.comparison import MultipleTextsAlignment + +recognizer = pipeline( + 'automatic-speech-recognition', + model='openai/whisper-large-v3', + chunk_length_s=20, + stride_length_s=(4, 2), + device='cuda:0', + model_kwargs={'attn_implementation': 'sdpa'}, + # torch_dtype=torch.float16, + generate_kwargs={ + 'language': '<|ru|>', + 'task': 'transcribe', + 'forced_decoder_ids': None + } +) +whisper_processor = WhisperProcessor.from_pretrained( + 'openai/whisper-large-v3', + language='Russian', + task='transcribe', +) + +def pipeline_transcribe_with_whisper( + waveform: str, + pipeline: Pipeline, +) -> str: + return pipeline(waveform)['text'] + +def longform_transcribe_with_whisper( + waveform: str, + processor: WhisperProcessor, + model: WhisperForConditionalGeneration, + condition_on_prev_tokens: bool = False, +) -> str: + # https://github.com/huggingface/transformers/pull/27658 + inputs = processor( + waveform, + return_tensors="pt", + truncation=False, + padding="longest", + return_attention_mask=True, + sampling_rate=16_000 + ).to("cuda") + result = model.generate( + **inputs, + condition_on_prev_tokens=condition_on_prev_tokens, + temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0), + logprob_threshold=-1.0, + compression_ratio_threshold=1.35, + return_timestamps=True, + language='<|ru|>', + task='transcribe', + ) + return whisper_processor.batch_decode(result, skip_special_tokens=True)[0] + +input_dir = Path('/home/oleg/pisets_test_set/') +output_dir = Path('/home/oleg/pisets_test_results/') + +for audio_path in input_dir.glob('*.wav'): + + if (srt_path := audio_path.with_suffix('.srt')).is_file(): + truth = ' '.join([sub.text for sub in pysrt.open(srt_path)]) + else: + truth = open(audio_path.with_suffix('.txt')).read() + + long_waveform, _ = librosa.load(audio_path, sr=16_000) + print(f'{audio_path.stem} {len(long_waveform) / 16_000} sec') + + pred = pipeline_transcribe_with_whisper(long_waveform, recognizer) + print('pipeline', MultipleTextsAlignment.from_strings(truth, pred).wer()) + with open(output_dir / f'{audio_path.stem}_only_whisper_pipeline.txt', 'w') as f: + f.write(pred) + + pred = longform_transcribe_with_whisper(long_waveform, whisper_processor, recognizer.model) + print('longform', MultipleTextsAlignment.from_strings(truth, pred).wer()) + with open(output_dir / f'{audio_path.stem}_only_whisper_longform.txt', 'w') as f: + f.write(pred) + + pred = longform_transcribe_with_whisper(long_waveform, whisper_processor, recognizer.model, condition_on_prev_tokens=True) + print('longform conditioned', MultipleTextsAlignment.from_strings(truth, pred).wer()) + with open(output_dir / f'{audio_path.stem}_only_whisper_longform_conditioned.txt', 'w') as f: + f.write(pred) \ No newline at end of file diff --git a/evaluation/get_pisets_results.py b/evaluation/get_pisets_results.py new file mode 100644 index 0000000..825e540 --- /dev/null +++ b/evaluation/get_pisets_results.py @@ -0,0 +1,56 @@ +import os +from pathlib import Path +import json +import dataclasses + +import librosa +import pysrt +import numpy as np +import pandas as pd +from datasets import load_dataset, Audio + +from IPython.display import clear_output +from wav_io.wav_io import load_sound +from asr.asr import * +from asr.comparison import * + +segmenter_no_lm = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos') +segmenter_lm = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm') +vad = initialize_model_for_speech_classification() +asr_whisper_large_v2 = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v2') +asr_whisper_large_v3 = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3') + +max_len = None + +class EnhancedJSONEncoder(json.JSONEncoder): + def default(self, o): + if dataclasses.is_dataclass(o): + return dataclasses.asdict(o) + return super().default(o) + +input_dir = Path('/home/oleg/pisets_test_set/') +output_dir = Path('/home/oleg/pisets_test_results/') + +for audio_path in input_dir.glob('*.wav'): + # if (srt_path := audio_path.with_suffix('.srt')).is_file(): + # truth = ' '.join([sub.text for sub in pysrt.open(srt_path)]) + # else: + # with open(audio_path.with_suffix('.txt')) as f: + # truth = f.read() + + name = audio_path.stem + + waveform, _ = librosa.load(audio_path, sr=16_000) + + for mode_name, args, kwargs in ( + ('nolm_whisperV3_1_20', (segmenter_no_lm, vad, asr_whisper_large_v3), dict(min_segment_size=1, max_segment_size=20)), + ('lm_whisperV2_1_20', (segmenter_lm, vad, asr_whisper_large_v2), dict(min_segment_size=1, max_segment_size=20)), + ('lm_whisperV3_15_25_stretch', (segmenter_lm, vad, asr_whisper_large_v3), dict(min_segment_size=15, max_segment_size=25, stretch=(3, 4))), + ('lm_whisperV3_1_20_stretch', (segmenter_lm, vad, asr_whisper_large_v3), dict(min_segment_size=1, max_segment_size=20, stretch=(3, 4))), + ('lm_whisperV3_1_30_stretch', (segmenter_lm, vad, asr_whisper_large_v3), dict(min_segment_size=1, max_segment_size=30, stretch=(3, 4))), + ): + print(name, mode_name) + output = transcribe(waveform[:max_len], *args, **kwargs) + with open(output_dir / f'{name}_{mode_name}.json', 'w') as f: + json.dump(output, f, cls=EnhancedJSONEncoder) + # print(' '.join(x.transcription for x in output)) \ No newline at end of file diff --git a/evaluation/my_pisets_results_summarize.ipynb b/evaluation/my_pisets_results_summarize.ipynb new file mode 100644 index 0000000..aaf1b0e --- /dev/null +++ b/evaluation/my_pisets_results_summarize.ipynb @@ -0,0 +1,492 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "import pysrt\n", + "from IPython.display import clear_output\n", + "\n", + "from asr.comparison import MultipleTextsAlignment" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "input_dir = Path('/home/oleg/pisets_test_results')\n", + "\n", + "transcriptions = {}\n", + "\n", + "for audio_path in Path('/home/oleg/pisets_test_set/').glob('*.wav'):\n", + " \n", + " transcriptions[audio_path.stem] = {}\n", + "\n", + " if (srt_path := audio_path.with_suffix('.srt')).is_file():\n", + " truth = ' '.join([sub.text for sub in pysrt.open(srt_path)])\n", + " else:\n", + " with open(audio_path.with_suffix('.txt')) as f:\n", + " truth = f.read()\n", + " transcriptions[audio_path.stem]['truth'] = truth\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_only_whisper_pipeline.txt') as f:\n", + " transcriptions[audio_path.stem]['only_whisper_pipeline'] = f.read()\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_only_whisper_longform.txt') as f:\n", + " transcriptions[audio_path.stem]['only_whisper_longform'] = f.read()\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_only_whisper_longform_conditioned.txt') as f:\n", + " transcriptions[audio_path.stem]['only_whisper_longform_conditioned'] = f.read()\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_stretch_3_to_4.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['w2v2_golos_lm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", + " transcriptions[audio_path.stem]['whisperV3'] = ' '.join([x['transcription'] for x in outputs])\n", + " transcriptions[audio_path.stem]['whisperV3_stretch'] = ' '.join([x['transcription_stretched'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_nolm_whisperV3.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['w2v2_golos_nolm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", + " # transcriptions[audio_path.stem]['whisperV3_from_golos_nolm'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['w2v2_golos_nolm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", + " # transcriptions[audio_path.stem]['whisperV3_from_golos_nolm'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_new.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['whisperV3_ru'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_1_20.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['whisperV3_1-20_ru'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_1_30.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['whisperV3_1-30_ru'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_long_segments.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['whisperV3_long_segments'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV2.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['w2v2_golos_lm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", + " transcriptions[audio_path.stem]['whisperV2'] = ' '.join([x['transcription'] for x in outputs])\n", + "\n", + " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_long_segments_new.json') as f:\n", + " outputs = json.load(f)\n", + " transcriptions[audio_path.stem]['whisperV3_long_segments_ru'] = ' '.join([x['transcription'] for x in outputs])" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
modelonly_whisper_longformonly_whisper_longform_conditionedonly_whisper_pipelinew2v2_golos_lmw2v2_golos_nolmwhisperV2whisperV3whisperV3_1-20_ruwhisperV3_1-30_ruwhisperV3_long_segmentswhisperV3_long_segments_ruwhisperV3_ruwhisperV3_stretch
audio
galore0.1663670.3460290.1552280.2759610.2759610.1760690.1602590.1322310.1282790.1311530.1311530.1519940.173194
harvard0.0109290.0573770.0455370.1498180.1498180.0701280.0359740.0159380.0145720.0122950.0122950.0341530.064208
lankov0.1030790.1449330.1473540.3168450.3168450.1611900.1338640.0875130.0875130.1141470.1141470.1293670.167416
savvateev0.1742760.1929310.1924400.6053020.6053020.3190970.2793320.2169860.2272950.1757490.1757490.2704960.432499
tuberculosis0.1695760.2101000.1995010.2793020.2793020.2503120.1536780.1312340.1571070.1596010.1596010.1483790.177993
zaliznyak0.1580860.3132700.1316170.2451050.2451050.1823790.1682380.1167510.1073240.1279910.1269040.1577230.207759
zhirinovsky0.0433710.0772410.1156550.2544400.2544400.1379600.0945890.0603060.0681540.0652620.0652620.0855020.136720
\n", + "
" + ], + "text/plain": [ + "model only_whisper_longform only_whisper_longform_conditioned \\\n", + "audio \n", + "galore 0.166367 0.346029 \n", + "harvard 0.010929 0.057377 \n", + "lankov 0.103079 0.144933 \n", + "savvateev 0.174276 0.192931 \n", + "tuberculosis 0.169576 0.210100 \n", + "zaliznyak 0.158086 0.313270 \n", + "zhirinovsky 0.043371 0.077241 \n", + "\n", + "model only_whisper_pipeline w2v2_golos_lm w2v2_golos_nolm \\\n", + "audio \n", + "galore 0.155228 0.275961 0.275961 \n", + "harvard 0.045537 0.149818 0.149818 \n", + "lankov 0.147354 0.316845 0.316845 \n", + "savvateev 0.192440 0.605302 0.605302 \n", + "tuberculosis 0.199501 0.279302 0.279302 \n", + "zaliznyak 0.131617 0.245105 0.245105 \n", + "zhirinovsky 0.115655 0.254440 0.254440 \n", + "\n", + "model whisperV2 whisperV3 whisperV3_1-20_ru whisperV3_1-30_ru \\\n", + "audio \n", + "galore 0.176069 0.160259 0.132231 0.128279 \n", + "harvard 0.070128 0.035974 0.015938 0.014572 \n", + "lankov 0.161190 0.133864 0.087513 0.087513 \n", + "savvateev 0.319097 0.279332 0.216986 0.227295 \n", + "tuberculosis 0.250312 0.153678 0.131234 0.157107 \n", + "zaliznyak 0.182379 0.168238 0.116751 0.107324 \n", + "zhirinovsky 0.137960 0.094589 0.060306 0.068154 \n", + "\n", + "model whisperV3_long_segments whisperV3_long_segments_ru \\\n", + "audio \n", + "galore 0.131153 0.131153 \n", + "harvard 0.012295 0.012295 \n", + "lankov 0.114147 0.114147 \n", + "savvateev 0.175749 0.175749 \n", + "tuberculosis 0.159601 0.159601 \n", + "zaliznyak 0.127991 0.126904 \n", + "zhirinovsky 0.065262 0.065262 \n", + "\n", + "model whisperV3_ru whisperV3_stretch \n", + "audio \n", + "galore 0.151994 0.173194 \n", + "harvard 0.034153 0.064208 \n", + "lankov 0.129367 0.167416 \n", + "savvateev 0.270496 0.432499 \n", + "tuberculosis 0.148379 0.177993 \n", + "zaliznyak 0.157723 0.207759 \n", + "zhirinovsky 0.085502 0.136720 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "wers = []\n", + "\n", + "for audio_name, t in transcriptions.items():\n", + " truth = t['truth']\n", + " for mode_name in set(t.keys()) - {'truth'}:\n", + " pred = t[mode_name]\n", + "\n", + " alignment = MultipleTextsAlignment.from_strings(truth, pred)\n", + " wers.append({'audio': audio_name, 'model': mode_name, 'wer': alignment.wer()['wer']}) # max_insertions=np.inf\n", + "\n", + " clear_output()\n", + "\n", + " df = pd.DataFrame(wers).pivot(index='audio', columns='model', values='wer')\n", + " display(df)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
modelpisets 1-20pisets 1-30only_whisper_longformonly_whisper_pipeline
audio
galore0.1322310.1282790.1663670.155228
harvard0.0159380.0145720.0109290.045537
lankov0.0875130.0875130.1030790.147354
savvateev0.2169860.2272950.1742760.192440
tuberculosis0.1312340.1571070.1695760.199501
zaliznyak0.1167510.1073240.1580860.131617
zhirinovsky0.0603060.0681540.0433710.115655
\n", + "
" + ], + "text/plain": [ + "model pisets 1-20 pisets 1-30 only_whisper_longform \\\n", + "audio \n", + "galore 0.132231 0.128279 0.166367 \n", + "harvard 0.015938 0.014572 0.010929 \n", + "lankov 0.087513 0.087513 0.103079 \n", + "savvateev 0.216986 0.227295 0.174276 \n", + "tuberculosis 0.131234 0.157107 0.169576 \n", + "zaliznyak 0.116751 0.107324 0.158086 \n", + "zhirinovsky 0.060306 0.068154 0.043371 \n", + "\n", + "model only_whisper_pipeline \n", + "audio \n", + "galore 0.155228 \n", + "harvard 0.045537 \n", + "lankov 0.147354 \n", + "savvateev 0.192440 \n", + "tuberculosis 0.199501 \n", + "zaliznyak 0.131617 \n", + "zhirinovsky 0.115655 " + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df[['whisperV3_1-20_ru', 'whisperV3_1-30_ru', 'only_whisper_longform', 'only_whisper_pipeline']] \\\n", + " .rename(columns={'whisperV3_1-20_ru': 'pisets 1-20', 'whisperV3_1-30_ru': 'pisets 1-30'})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/evaluation/requirements.txt b/evaluation/requirements.txt new file mode 100644 index 0000000..eef8b5c --- /dev/null +++ b/evaluation/requirements.txt @@ -0,0 +1,3 @@ +pysrt +soundfile>=0.12.1 +librosa \ No newline at end of file diff --git a/evaluation/simple_eval.ipynb b/evaluation/simple_eval.ipynb new file mode 100644 index 0000000..272514b --- /dev/null +++ b/evaluation/simple_eval.ipynb @@ -0,0 +1,336 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "import json\n", + "from pathlib import Path\n", + "from typing import Callable, Literal\n", + "from dataclasses import dataclass\n", + "\n", + "import torch\n", + "import numpy as np\n", + "from datasets import load_dataset, Audio\n", + "from transformers import pipeline, Pipeline, WhisperProcessor\n", + "\n", + "from asr.asr import (\n", + " initialize_model_for_speech_segmentation,\n", + " initialize_model_for_speech_classification,\n", + " initialize_model_for_speech_recognition,\n", + " transcribe\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "class TranscribeWhisperPipeline:\n", + " \"\"\"\n", + " A Whisper baseline to compare with `TranscribePisets`.\n", + " \"\"\"\n", + " def __init__(self, predictions_name: str):\n", + " self.predictions_name = predictions_name\n", + " self.whisper_pipeline = pipeline(\n", + " 'automatic-speech-recognition',\n", + " model='openai/whisper-large-v3',\n", + " chunk_length_s=20,\n", + " stride_length_s=(4, 2),\n", + " device='cuda:0',\n", + " model_kwargs={'attn_implementation': 'sdpa'},\n", + " # torch_dtype=torch.float16,\n", + " generate_kwargs={\n", + " 'language': '<|ru|>',\n", + " 'task': 'transcribe',\n", + " 'forced_decoder_ids': None\n", + " }\n", + " )\n", + " \n", + " def __call__(self, waveform: np.ndarray) -> dict[str, str]:\n", + " return self.whisper_pipeline(waveform)['text']\n", + "\n", + "\n", + "class TranscribeWhisperLongform(TranscribeWhisperPipeline):\n", + " \"\"\"\n", + " A Whisper longform baseline to compare with `TranscribePisets`.\n", + " \"\"\"\n", + " def __init__(self, predictions_name: str, condition_on_prev_tokens: bool):\n", + " super().__init__(predictions_name)\n", + " self.whisper_processor = WhisperProcessor.from_pretrained(\n", + " 'openai/whisper-large-v3',\n", + " language='Russian',\n", + " task='transcribe',\n", + " )\n", + " self.condition_on_prev_tokens = condition_on_prev_tokens\n", + " \n", + " def __call__(self, waveform: np.ndarray) -> dict[str, str]:\n", + " # https://github.com/huggingface/transformers/pull/27658\n", + " inputs = self.whisper_processor(\n", + " waveform,\n", + " return_tensors='pt',\n", + " truncation=False,\n", + " padding='longest',\n", + " return_attention_mask=True, # probably we do not need this for Whisper\n", + " sampling_rate=16_000\n", + " )\n", + " result = self.whisper_pipeline.model.generate(\n", + " **inputs.to('cuda'),\n", + " condition_on_prev_tokens=self.condition_on_prev_tokens,\n", + " temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0),\n", + " logprob_threshold=-1.0,\n", + " compression_ratio_threshold=1.35,\n", + " return_timestamps=True,\n", + " language='<|ru|>',\n", + " task='transcribe',\n", + " )\n", + " return self.whisper_processor.batch_decode(result, skip_special_tokens=True)[0]\n", + "\n", + "\n", + "@dataclass\n", + "class TranscribePisets:\n", + " \"\"\"\n", + " A Pisets wrapper for evaluation purposes.\n", + " \n", + " Transcribes waveform with Pisets and returns results for all stages.\n", + "\n", + " In contrast to asr.asr.transcribe() this class:\n", + " - Concatenates transcriptions for all segments\n", + " - Does not return timestamps\n", + " - Allows to define custom names for all stages\n", + " \"\"\"\n", + " \n", + " segmenter: Pipeline | Callable\n", + " vad: Pipeline | Callable | Literal['skip']\n", + " asr: Pipeline | Callable | Literal['skip']\n", + "\n", + " min_segment_size: int = 1\n", + " max_segment_size: int = 20\n", + " stretch: tuple[int, int] | None = None\n", + "\n", + " segmenter_predictions_name: str | None = None\n", + " asr_predictions_name: str | None = None\n", + " asr_stretched_predictions_name: str | None = None\n", + " \n", + " def __call__(self, waveform: np.ndarray) -> dict[str, str]:\n", + " # transcribing\n", + " outputs = transcribe(\n", + " waveform,\n", + " segmenter=self.segmenter,\n", + " voice_activity_detector=(\n", + " self.vad\n", + " if self.vad != 'skip'\n", + " else (lambda audio: [{'score': 1, 'label': 'Speech'}])\n", + " ),\n", + " asr=(\n", + " self.asr\n", + " if self.asr != 'skip'\n", + " else (lambda audio: {'text': ''})\n", + " ),\n", + " min_segment_size=self.min_segment_size,\n", + " max_segment_size=self.max_segment_size,\n", + " stretch=self.stretch,\n", + " )\n", + " # concatenating segments\n", + " results = {}\n", + " if self.segmenter_predictions_name is not None:\n", + " results[self.segmenter_predictions_name] = [s.transcription_from_segmenter for s in outputs]\n", + " if self.asr_predictions_name is not None:\n", + " results[self.asr_predictions_name] = [s.transcription for s in outputs]\n", + " if self.asr_stretched_predictions_name is not None:\n", + " results[self.asr_stretched_predictions_name] = [s.transcription_stretched for s in outputs]\n", + " return results" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# defining transcribers without instantiating them all at once to save GPU memory\n", + "\n", + "transcribers = {\n", + " 'Whisper pipeline': lambda: TranscribeWhisperPipeline(\n", + " predictions_name='Baseline Whisper pipeline',\n", + " ),\n", + " 'Whisper longform': lambda: TranscribeWhisperLongform(\n", + " predictions_name='Baseline Whisper longform',\n", + " condition_on_prev_tokens=False,\n", + " ),\n", + " 'Whisper longform conditioned': lambda: TranscribeWhisperLongform(\n", + " predictions_name='Baseline Whisper longform conditioned',\n", + " condition_on_prev_tokens=True,\n", + " ),\n", + " 'Pisets (segments 1s-20s)': lambda: TranscribePisets(\n", + " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", + " vad=initialize_model_for_speech_classification(),\n", + " asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'),\n", + " min_segment_size=1,\n", + " max_segment_size=20,\n", + " stretch=(3, 4),\n", + " segmenter_predictions_name='W2V2 Golos LM',\n", + " asr_predictions_name='Pisets WhisperV3 (segments 1s-20s)',\n", + " asr_stretched_predictions_name='Pisets WhisperV3 stretched (segments 1s-20s)',\n", + " ),\n", + " 'Pisets (segments 10s-30s)': lambda: TranscribePisets(\n", + " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", + " vad=initialize_model_for_speech_classification(),\n", + " asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'),\n", + " min_segment_size=10,\n", + " max_segment_size=30,\n", + " asr_predictions_name='Pisets WhisperV3 (segments 10s-30s)',\n", + " ),\n", + " 'W2V2 golos no LM': lambda: TranscribePisets(\n", + " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos'),\n", + " vad='skip',\n", + " asr='skip',\n", + " segmenter_predictions_name='W2V2 Golos no LM',\n", + " ),\n", + " 'Pisets Podlodka': lambda: TranscribePisets(\n", + " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", + " vad=initialize_model_for_speech_classification(),\n", + " asr=initialize_model_for_speech_recognition('ru', 'bond005/whisper-large-v3-ru-podlodka'),\n", + " min_segment_size=1,\n", + " max_segment_size=20,\n", + " asr_predictions_name='Pisets WhisperV3 Podlodka (segments 1s-20s)',\n", + " ),\n", + " 'Pisets no-VAD': lambda: TranscribePisets(\n", + " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", + " vad='skip',\n", + " asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'),\n", + " min_segment_size=1,\n", + " max_segment_size=20,\n", + " asr_predictions_name='Pisets WhisperV3 no-VAD (segments 1s-20s)',\n", + " ),\n", + " 'Pisets no-VAD Podlodka': lambda: TranscribePisets(\n", + " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", + " vad='skip',\n", + " asr=initialize_model_for_speech_recognition('ru', 'bond005/whisper-large-v3-ru-podlodka'),\n", + " min_segment_size=1,\n", + " max_segment_size=20,\n", + " asr_predictions_name='Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s)',\n", + " ),\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = (\n", + " load_dataset('dangrebenkin/long_audio_youtube_lectures')\n", + " .cast_column('audio', Audio(sampling_rate=16_000))\n", + " ['train']\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "output_dir = Path('/home/oleg/pisets_test_results')\n", + "output_dir.mkdir(parents=True, exist_ok=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/home/oleg/pisets_test_results/zaliznyak Whisper pipeline.json\n", + "GPU max allocated memory: 6.37 GB\n", + "/home/oleg/pisets_test_results/harvard Whisper pipeline.json\n", + "GPU max allocated memory: 6.37 GB\n", + "/home/oleg/pisets_test_results/savvateev Whisper pipeline.json\n", + "GPU max allocated memory: 6.36 GB\n", + "/home/oleg/pisets_test_results/zhirinovsky Whisper pipeline.json\n", + "GPU max allocated memory: 6.37 GB\n", + "/home/oleg/pisets_test_results/lankov Whisper pipeline.json\n", + "GPU max allocated memory: 6.37 GB\n", + "/home/oleg/pisets_test_results/kolodezev Whisper pipeline.json\n", + "GPU max allocated memory: 6.36 GB\n", + "/home/oleg/pisets_test_results/tuberculosis Whisper pipeline.json\n", + "GPU max allocated memory: 6.37 GB\n", + "/home/oleg/pisets_test_results/zaliznyak Whisper longform.json\n", + "GPU max allocated memory: 6.37 GB\n", + "/home/oleg/pisets_test_results/harvard Whisper longform.json\n", + "GPU max allocated memory: 6.39 GB\n", + "/home/oleg/pisets_test_results/savvateev Whisper longform.json\n" + ] + } + ], + "source": [ + "for transcriber_name, transcriber_lambda in transcribers.items():\n", + "\n", + " # instantiate transcriber on GPU\n", + " transcriber = transcriber_lambda()\n", + "\n", + " for sample in dataset:\n", + " print(filepath := output_dir / f'{sample[\"name\"]} {transcriber_name}.json')\n", + "\n", + " torch.cuda.reset_peak_memory_stats()\n", + "\n", + " if filepath.is_file():\n", + " print(f'{str(filepath)} already exists')\n", + " continue\n", + "\n", + " start_time = time.time()\n", + " transcriptions = transcriber(sample['audio']['array'][:160_000])\n", + " \n", + " results = {\n", + " 'audio_name': sample['name'],\n", + " 'transcriber_name': transcriber_name,\n", + " 'elapsed_time': time.time() - start_time,\n", + " 'transcriptions': transcriptions,\n", + " }\n", + "\n", + " with open(filepath, 'w') as f:\n", + " json.dump(results, f)\n", + "\n", + " print(f'GPU max allocated memory: {torch.cuda.max_memory_allocated(0) / 2**30:.2f} GB')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From e872b265df3e32a4d25c989601cea1fbbf55fff5 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sat, 30 Nov 2024 19:35:02 +0300 Subject: [PATCH 17/24] evaluation script --- evaluation/eval.py | 234 ++++++++++++++++++++++++ evaluation/simple_eval.ipynb | 336 ----------------------------------- requirements.txt | 4 +- 3 files changed, 237 insertions(+), 337 deletions(-) create mode 100644 evaluation/eval.py delete mode 100644 evaluation/simple_eval.ipynb diff --git a/evaluation/eval.py b/evaluation/eval.py new file mode 100644 index 0000000..e06e2b5 --- /dev/null +++ b/evaluation/eval.py @@ -0,0 +1,234 @@ +import time +import json +from pathlib import Path +from typing import Callable, Literal +from dataclasses import dataclass + +import torch +import numpy as np +from datasets import load_dataset, Audio +from transformers import pipeline, Pipeline, WhisperProcessor + +from asr.asr import ( + initialize_model_for_speech_segmentation, + initialize_model_for_speech_classification, + initialize_model_for_speech_recognition, + transcribe +) + +class TranscribeWhisperPipeline: + """ + A Whisper baseline to compare with `TranscribePisets`. + """ + def __init__(self, predictions_name: str): + self.predictions_name = predictions_name + self.whisper_pipeline = pipeline( + 'automatic-speech-recognition', + model='openai/whisper-large-v3', + chunk_length_s=20, + stride_length_s=(4, 2), + device='cuda:0', + model_kwargs={'attn_implementation': 'sdpa'}, + # torch_dtype=torch.float16, + generate_kwargs={ + 'language': '<|ru|>', + 'task': 'transcribe', + 'forced_decoder_ids': None + } + ) + + def __call__(self, waveform: np.ndarray) -> dict[str, str]: + return self.whisper_pipeline(waveform)['text'] + + +class TranscribeWhisperLongform(TranscribeWhisperPipeline): + """ + A Whisper longform baseline to compare with `TranscribePisets`. + """ + def __init__(self, predictions_name: str, condition_on_prev_tokens: bool): + super().__init__(predictions_name) + self.whisper_processor = WhisperProcessor.from_pretrained( + 'openai/whisper-large-v3', + language='Russian', + task='transcribe', + ) + self.condition_on_prev_tokens = condition_on_prev_tokens + + def __call__(self, waveform: np.ndarray) -> dict[str, str]: + # https://github.com/huggingface/transformers/pull/27658 + inputs = self.whisper_processor( + waveform, + return_tensors='pt', + truncation=False, + padding='longest', + return_attention_mask=True, # probably we do not need this for Whisper + sampling_rate=16_000 + ) + result = self.whisper_pipeline.model.generate( + **inputs.to('cuda'), + condition_on_prev_tokens=self.condition_on_prev_tokens, + temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0), + logprob_threshold=-1.0, + compression_ratio_threshold=1.35, + return_timestamps=True, + language='<|ru|>', + task='transcribe', + ) + return self.whisper_processor.batch_decode(result, skip_special_tokens=True)[0] + + +@dataclass +class TranscribePisets: + """ + A Pisets wrapper for evaluation purposes. + + Transcribes waveform with Pisets and returns results for all stages. + + In contrast to asr.asr.transcribe() this class: + - Concatenates transcriptions for all segments + - Does not return timestamps + - Allows to define custom names for all stages + """ + + segmenter: Pipeline | Callable + vad: Pipeline | Callable | Literal['skip'] + asr: Pipeline | Callable | Literal['skip'] + + min_segment_size: int = 1 + max_segment_size: int = 20 + stretch: tuple[int, int] | None = None + + segmenter_predictions_name: str | None = None + asr_predictions_name: str | None = None + asr_stretched_predictions_name: str | None = None + + def __call__(self, waveform: np.ndarray) -> dict[str, str]: + # transcribing + outputs = transcribe( + waveform, + segmenter=self.segmenter, + voice_activity_detector=( + self.vad + if self.vad != 'skip' + else (lambda audio: [{'score': 1, 'label': 'Speech'}]) + ), + asr=( + self.asr + if self.asr != 'skip' + else (lambda audio: {'text': ''}) + ), + min_segment_size=self.min_segment_size, + max_segment_size=self.max_segment_size, + stretch=self.stretch, + ) + # concatenating segments + results = {} + if self.segmenter_predictions_name is not None: + results[self.segmenter_predictions_name] = ' '.join([s.transcription_from_segmenter for s in outputs]) + if self.asr_predictions_name is not None: + results[self.asr_predictions_name] = ' '.join([s.transcription for s in outputs]) + if self.asr_stretched_predictions_name is not None: + results[self.asr_stretched_predictions_name] = ' '.join([s.transcription_stretched for s in outputs]) + return results + +# defining transcribers without instantiating them all at once to save GPU memory + +transcribers = { + 'Whisper pipeline': lambda: TranscribeWhisperPipeline( + predictions_name='Baseline Whisper pipeline', + ), + 'Whisper longform': lambda: TranscribeWhisperLongform( + predictions_name='Baseline Whisper longform', + condition_on_prev_tokens=False, + ), + 'Whisper longform conditioned': lambda: TranscribeWhisperLongform( + predictions_name='Baseline Whisper longform conditioned', + condition_on_prev_tokens=True, + ), + 'Pisets (segments 1s-20s)': lambda: TranscribePisets( + segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), + vad=initialize_model_for_speech_classification(), + asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'), + min_segment_size=1, + max_segment_size=20, + stretch=(3, 4), + segmenter_predictions_name='W2V2 Golos LM', + asr_predictions_name='Pisets WhisperV3 (segments 1s-20s)', + asr_stretched_predictions_name='Pisets WhisperV3 stretched (segments 1s-20s)', + ), + 'Pisets (segments 10s-30s)': lambda: TranscribePisets( + segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), + vad=initialize_model_for_speech_classification(), + asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'), + min_segment_size=10, + max_segment_size=30, + asr_predictions_name='Pisets WhisperV3 (segments 10s-30s)', + ), + 'W2V2 golos no LM': lambda: TranscribePisets( + segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos'), + vad='skip', + asr='skip', + segmenter_predictions_name='W2V2 Golos no LM', + ), + 'Pisets Podlodka': lambda: TranscribePisets( + segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), + vad=initialize_model_for_speech_classification(), + asr=initialize_model_for_speech_recognition('ru', 'bond005/whisper-large-v3-ru-podlodka'), + min_segment_size=1, + max_segment_size=20, + asr_predictions_name='Pisets WhisperV3 Podlodka (segments 1s-20s)', + ), + 'Pisets no-VAD': lambda: TranscribePisets( + segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), + vad='skip', + asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'), + min_segment_size=1, + max_segment_size=20, + asr_predictions_name='Pisets WhisperV3 no-VAD (segments 1s-20s)', + ), + 'Pisets no-VAD Podlodka': lambda: TranscribePisets( + segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), + vad='skip', + asr=initialize_model_for_speech_recognition('ru', 'bond005/whisper-large-v3-ru-podlodka'), + min_segment_size=1, + max_segment_size=20, + asr_predictions_name='Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s)', + ), +} + +dataset = ( + load_dataset('dangrebenkin/long_audio_youtube_lectures') + .cast_column('audio', Audio(sampling_rate=16_000)) + ['train'] +) + +output_dir = Path('/home/oleg/pisets_test_results') +output_dir.mkdir(parents=True, exist_ok=True) + +for transcriber_name, transcriber_lambda in transcribers.items(): + + # instantiate transcriber on GPU + transcriber = transcriber_lambda() + + for sample in dataset: + print(filepath := output_dir / f'{sample["name"]} {transcriber_name}.json') + + torch.cuda.reset_peak_memory_stats() + + if filepath.is_file(): + print(f'Already exists') + continue + + start_time = time.time() + transcriptions = transcriber(sample['audio']['array']) + print('Elapsed', elapsed_time := time.time() - start_time) + + with open(filepath, 'w') as f: + json.dump({ + 'audio_name': sample['name'], + 'transcriber_name': transcriber_name, + 'elapsed_time': elapsed_time, + 'transcriptions': transcriptions, + }, f) + + print(f'GPU max allocated memory: {torch.cuda.max_memory_allocated(0) / 2**30:.2f} GB') \ No newline at end of file diff --git a/evaluation/simple_eval.ipynb b/evaluation/simple_eval.ipynb deleted file mode 100644 index 272514b..0000000 --- a/evaluation/simple_eval.ipynb +++ /dev/null @@ -1,336 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "import time\n", - "import json\n", - "from pathlib import Path\n", - "from typing import Callable, Literal\n", - "from dataclasses import dataclass\n", - "\n", - "import torch\n", - "import numpy as np\n", - "from datasets import load_dataset, Audio\n", - "from transformers import pipeline, Pipeline, WhisperProcessor\n", - "\n", - "from asr.asr import (\n", - " initialize_model_for_speech_segmentation,\n", - " initialize_model_for_speech_classification,\n", - " initialize_model_for_speech_recognition,\n", - " transcribe\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "class TranscribeWhisperPipeline:\n", - " \"\"\"\n", - " A Whisper baseline to compare with `TranscribePisets`.\n", - " \"\"\"\n", - " def __init__(self, predictions_name: str):\n", - " self.predictions_name = predictions_name\n", - " self.whisper_pipeline = pipeline(\n", - " 'automatic-speech-recognition',\n", - " model='openai/whisper-large-v3',\n", - " chunk_length_s=20,\n", - " stride_length_s=(4, 2),\n", - " device='cuda:0',\n", - " model_kwargs={'attn_implementation': 'sdpa'},\n", - " # torch_dtype=torch.float16,\n", - " generate_kwargs={\n", - " 'language': '<|ru|>',\n", - " 'task': 'transcribe',\n", - " 'forced_decoder_ids': None\n", - " }\n", - " )\n", - " \n", - " def __call__(self, waveform: np.ndarray) -> dict[str, str]:\n", - " return self.whisper_pipeline(waveform)['text']\n", - "\n", - "\n", - "class TranscribeWhisperLongform(TranscribeWhisperPipeline):\n", - " \"\"\"\n", - " A Whisper longform baseline to compare with `TranscribePisets`.\n", - " \"\"\"\n", - " def __init__(self, predictions_name: str, condition_on_prev_tokens: bool):\n", - " super().__init__(predictions_name)\n", - " self.whisper_processor = WhisperProcessor.from_pretrained(\n", - " 'openai/whisper-large-v3',\n", - " language='Russian',\n", - " task='transcribe',\n", - " )\n", - " self.condition_on_prev_tokens = condition_on_prev_tokens\n", - " \n", - " def __call__(self, waveform: np.ndarray) -> dict[str, str]:\n", - " # https://github.com/huggingface/transformers/pull/27658\n", - " inputs = self.whisper_processor(\n", - " waveform,\n", - " return_tensors='pt',\n", - " truncation=False,\n", - " padding='longest',\n", - " return_attention_mask=True, # probably we do not need this for Whisper\n", - " sampling_rate=16_000\n", - " )\n", - " result = self.whisper_pipeline.model.generate(\n", - " **inputs.to('cuda'),\n", - " condition_on_prev_tokens=self.condition_on_prev_tokens,\n", - " temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0),\n", - " logprob_threshold=-1.0,\n", - " compression_ratio_threshold=1.35,\n", - " return_timestamps=True,\n", - " language='<|ru|>',\n", - " task='transcribe',\n", - " )\n", - " return self.whisper_processor.batch_decode(result, skip_special_tokens=True)[0]\n", - "\n", - "\n", - "@dataclass\n", - "class TranscribePisets:\n", - " \"\"\"\n", - " A Pisets wrapper for evaluation purposes.\n", - " \n", - " Transcribes waveform with Pisets and returns results for all stages.\n", - "\n", - " In contrast to asr.asr.transcribe() this class:\n", - " - Concatenates transcriptions for all segments\n", - " - Does not return timestamps\n", - " - Allows to define custom names for all stages\n", - " \"\"\"\n", - " \n", - " segmenter: Pipeline | Callable\n", - " vad: Pipeline | Callable | Literal['skip']\n", - " asr: Pipeline | Callable | Literal['skip']\n", - "\n", - " min_segment_size: int = 1\n", - " max_segment_size: int = 20\n", - " stretch: tuple[int, int] | None = None\n", - "\n", - " segmenter_predictions_name: str | None = None\n", - " asr_predictions_name: str | None = None\n", - " asr_stretched_predictions_name: str | None = None\n", - " \n", - " def __call__(self, waveform: np.ndarray) -> dict[str, str]:\n", - " # transcribing\n", - " outputs = transcribe(\n", - " waveform,\n", - " segmenter=self.segmenter,\n", - " voice_activity_detector=(\n", - " self.vad\n", - " if self.vad != 'skip'\n", - " else (lambda audio: [{'score': 1, 'label': 'Speech'}])\n", - " ),\n", - " asr=(\n", - " self.asr\n", - " if self.asr != 'skip'\n", - " else (lambda audio: {'text': ''})\n", - " ),\n", - " min_segment_size=self.min_segment_size,\n", - " max_segment_size=self.max_segment_size,\n", - " stretch=self.stretch,\n", - " )\n", - " # concatenating segments\n", - " results = {}\n", - " if self.segmenter_predictions_name is not None:\n", - " results[self.segmenter_predictions_name] = [s.transcription_from_segmenter for s in outputs]\n", - " if self.asr_predictions_name is not None:\n", - " results[self.asr_predictions_name] = [s.transcription for s in outputs]\n", - " if self.asr_stretched_predictions_name is not None:\n", - " results[self.asr_stretched_predictions_name] = [s.transcription_stretched for s in outputs]\n", - " return results" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "# defining transcribers without instantiating them all at once to save GPU memory\n", - "\n", - "transcribers = {\n", - " 'Whisper pipeline': lambda: TranscribeWhisperPipeline(\n", - " predictions_name='Baseline Whisper pipeline',\n", - " ),\n", - " 'Whisper longform': lambda: TranscribeWhisperLongform(\n", - " predictions_name='Baseline Whisper longform',\n", - " condition_on_prev_tokens=False,\n", - " ),\n", - " 'Whisper longform conditioned': lambda: TranscribeWhisperLongform(\n", - " predictions_name='Baseline Whisper longform conditioned',\n", - " condition_on_prev_tokens=True,\n", - " ),\n", - " 'Pisets (segments 1s-20s)': lambda: TranscribePisets(\n", - " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", - " vad=initialize_model_for_speech_classification(),\n", - " asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'),\n", - " min_segment_size=1,\n", - " max_segment_size=20,\n", - " stretch=(3, 4),\n", - " segmenter_predictions_name='W2V2 Golos LM',\n", - " asr_predictions_name='Pisets WhisperV3 (segments 1s-20s)',\n", - " asr_stretched_predictions_name='Pisets WhisperV3 stretched (segments 1s-20s)',\n", - " ),\n", - " 'Pisets (segments 10s-30s)': lambda: TranscribePisets(\n", - " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", - " vad=initialize_model_for_speech_classification(),\n", - " asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'),\n", - " min_segment_size=10,\n", - " max_segment_size=30,\n", - " asr_predictions_name='Pisets WhisperV3 (segments 10s-30s)',\n", - " ),\n", - " 'W2V2 golos no LM': lambda: TranscribePisets(\n", - " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos'),\n", - " vad='skip',\n", - " asr='skip',\n", - " segmenter_predictions_name='W2V2 Golos no LM',\n", - " ),\n", - " 'Pisets Podlodka': lambda: TranscribePisets(\n", - " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", - " vad=initialize_model_for_speech_classification(),\n", - " asr=initialize_model_for_speech_recognition('ru', 'bond005/whisper-large-v3-ru-podlodka'),\n", - " min_segment_size=1,\n", - " max_segment_size=20,\n", - " asr_predictions_name='Pisets WhisperV3 Podlodka (segments 1s-20s)',\n", - " ),\n", - " 'Pisets no-VAD': lambda: TranscribePisets(\n", - " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", - " vad='skip',\n", - " asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'),\n", - " min_segment_size=1,\n", - " max_segment_size=20,\n", - " asr_predictions_name='Pisets WhisperV3 no-VAD (segments 1s-20s)',\n", - " ),\n", - " 'Pisets no-VAD Podlodka': lambda: TranscribePisets(\n", - " segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'),\n", - " vad='skip',\n", - " asr=initialize_model_for_speech_recognition('ru', 'bond005/whisper-large-v3-ru-podlodka'),\n", - " min_segment_size=1,\n", - " max_segment_size=20,\n", - " asr_predictions_name='Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s)',\n", - " ),\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [], - "source": [ - "dataset = (\n", - " load_dataset('dangrebenkin/long_audio_youtube_lectures')\n", - " .cast_column('audio', Audio(sampling_rate=16_000))\n", - " ['train']\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "output_dir = Path('/home/oleg/pisets_test_results')\n", - "output_dir.mkdir(parents=True, exist_ok=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/home/oleg/pisets_test_results/zaliznyak Whisper pipeline.json\n", - "GPU max allocated memory: 6.37 GB\n", - "/home/oleg/pisets_test_results/harvard Whisper pipeline.json\n", - "GPU max allocated memory: 6.37 GB\n", - "/home/oleg/pisets_test_results/savvateev Whisper pipeline.json\n", - "GPU max allocated memory: 6.36 GB\n", - "/home/oleg/pisets_test_results/zhirinovsky Whisper pipeline.json\n", - "GPU max allocated memory: 6.37 GB\n", - "/home/oleg/pisets_test_results/lankov Whisper pipeline.json\n", - "GPU max allocated memory: 6.37 GB\n", - "/home/oleg/pisets_test_results/kolodezev Whisper pipeline.json\n", - "GPU max allocated memory: 6.36 GB\n", - "/home/oleg/pisets_test_results/tuberculosis Whisper pipeline.json\n", - "GPU max allocated memory: 6.37 GB\n", - "/home/oleg/pisets_test_results/zaliznyak Whisper longform.json\n", - "GPU max allocated memory: 6.37 GB\n", - "/home/oleg/pisets_test_results/harvard Whisper longform.json\n", - "GPU max allocated memory: 6.39 GB\n", - "/home/oleg/pisets_test_results/savvateev Whisper longform.json\n" - ] - } - ], - "source": [ - "for transcriber_name, transcriber_lambda in transcribers.items():\n", - "\n", - " # instantiate transcriber on GPU\n", - " transcriber = transcriber_lambda()\n", - "\n", - " for sample in dataset:\n", - " print(filepath := output_dir / f'{sample[\"name\"]} {transcriber_name}.json')\n", - "\n", - " torch.cuda.reset_peak_memory_stats()\n", - "\n", - " if filepath.is_file():\n", - " print(f'{str(filepath)} already exists')\n", - " continue\n", - "\n", - " start_time = time.time()\n", - " transcriptions = transcriber(sample['audio']['array'][:160_000])\n", - " \n", - " results = {\n", - " 'audio_name': sample['name'],\n", - " 'transcriber_name': transcriber_name,\n", - " 'elapsed_time': time.time() - start_time,\n", - " 'transcriptions': transcriptions,\n", - " }\n", - "\n", - " with open(filepath, 'w') as f:\n", - " json.dump(results, f)\n", - "\n", - " print(f'GPU max allocated memory: {torch.cuda.max_memory_allocated(0) / 2**30:.2f} GB')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/requirements.txt b/requirements.txt index 4801078..e715475 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,6 @@ tokenizers>=0.19.1 transformers>=4.41.2 webrtcvad>=2.0.10 setuptools -pymystem3 \ No newline at end of file +pymystem3 +kenlm +pyctcdecode \ No newline at end of file From d170c0e90e7b84366cb332c32cd40efcf8a2bf55 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sat, 30 Nov 2024 20:57:42 +0300 Subject: [PATCH 18/24] clean files --- evaluation/Make dataset.ipynb | 200 ----------------------------- evaluation/get_baseline_results.py | 87 ------------- evaluation/get_pisets_results.py | 56 -------- 3 files changed, 343 deletions(-) delete mode 100644 evaluation/Make dataset.ipynb delete mode 100644 evaluation/get_baseline_results.py delete mode 100644 evaluation/get_pisets_results.py diff --git a/evaluation/Make dataset.ipynb b/evaluation/Make dataset.ipynb deleted file mode 100644 index 4f71b39..0000000 --- a/evaluation/Make dataset.ipynb +++ /dev/null @@ -1,200 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "from pathlib import Path\n", - "from itertools import combinations\n", - "from typing import Any\n", - "\n", - "import pandas as pd\n", - "import numpy as np\n", - "import torch\n", - "from transformers import (\n", - " AutoModelWithLMHead, AutoTokenizer, pipeline, Pipeline,\n", - " WhisperProcessor, WhisperForConditionalGeneration\n", - ")\n", - "import pysrt\n", - "from IPython.display import clear_output\n", - "import IPython.display\n", - "import librosa\n", - "\n", - "from asr.asr import (\n", - " initialize_model_for_speech_segmentation, initialize_model_for_speech_classification,\n", - " initialize_model_for_speech_recognition\n", - ")\n", - "from asr.comparison import MultipleTextsAlignment, filter_correction_suggestions" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "/home/oleg/pisets_test_set\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/oleg/pisets/venv/lib/python3.12/site-packages/IPython/core/magics/osm.py:417: UserWarning: This is now an optional IPython functionality, setting dhist requires you to install the `pickleshare` library.\n", - " self.shell.db['dhist'] = compress_dhist(dhist)[-100:]\n" - ] - } - ], - "source": [ - "%cd ../pisets_test_set" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# import pysrt\n", - "\n", - "# for name in ['galore', 'tuberculosis']:\n", - "# truth = ' '.join([sub.text for sub in pysrt.open(name + '.srt')])\n", - "# with open(name + '.txt', 'w') as f:\n", - "# f.write(truth)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import librosa\n", - "from tqdm.auto import tqdm\n", - "from datasets import Dataset, Audio\n", - "\n", - "metainfo = {\n", - " 'zaliznyak': {'noise': 'reverberation, background speech', 'domain': 'philology'},\n", - " 'harvard': {'noise': 'background speech', 'domain': 'philosophy'},\n", - " 'savvateev': {'noise': 'reverberation, background speech', 'domain': 'mathematics'},\n", - " 'zhirinovsky': {'noise': 'reverberation, background speech', 'domain': 'politics'},\n", - " 'lankov': {'noise': 'reverberation, background speech', 'domain': 'history'},\n", - " 'kolodezev': {'noise': 'unknown (TODO)', 'domain': 'machine learning'},\n", - " 'tuberculosis': {'noise': 'unknown (TODO)', 'domain': 'medicine'},\n", - "}\n", - "\n", - "samples = []\n", - "\n", - "for name in tqdm(metainfo):\n", - " waveform, _ = librosa.load(f'{name}.wav', sr=16_000)\n", - " with open(f'{name}.txt') as f:\n", - " transcription = f.read()\n", - "\n", - " samples.append({\n", - " 'name': name,\n", - " 'audio': {'array': waveform, 'sampling_rate': 16_000},\n", - " 'transcription': transcription,\n", - " **metainfo[name],\n", - " })\n", - "\n", - "dataset = Dataset.from_list(samples) #.cast_column(\"audio\", Audio(decode=False))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# https://github.com/huggingface/datasets/issues/6703#issuecomment-1974761165\n", - "dataset.to_parquet('long_audio_youtube_dataset/data.parquet')" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "DatasetDict({\n", - " train: Dataset({\n", - " features: ['name', 'audio', 'transcription', 'noise', 'domain'],\n", - " num_rows: 7\n", - " })\n", - "})" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from datasets import load_dataset\n", - "dataset = load_dataset('long_audio_youtube_dataset')\n", - "dataset" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'path': None,\n", - " 'array': array([0.00042725, 0.00112915, 0.00146484, ..., 0.00222778, 0.00164795,\n", - " 0.00262451]),\n", - " 'sampling_rate': 16000}" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from datasets import Audio\n", - "\n", - "dataset.cast_column(\"audio\", Audio(sampling_rate=16_000))['train'][0]['audio']" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/evaluation/get_baseline_results.py b/evaluation/get_baseline_results.py deleted file mode 100644 index f0c5588..0000000 --- a/evaluation/get_baseline_results.py +++ /dev/null @@ -1,87 +0,0 @@ -from pathlib import Path -from transformers import pipeline, Pipeline, WhisperProcessor, WhisperForConditionalGeneration -import pysrt -import librosa - -from asr.comparison import MultipleTextsAlignment - -recognizer = pipeline( - 'automatic-speech-recognition', - model='openai/whisper-large-v3', - chunk_length_s=20, - stride_length_s=(4, 2), - device='cuda:0', - model_kwargs={'attn_implementation': 'sdpa'}, - # torch_dtype=torch.float16, - generate_kwargs={ - 'language': '<|ru|>', - 'task': 'transcribe', - 'forced_decoder_ids': None - } -) -whisper_processor = WhisperProcessor.from_pretrained( - 'openai/whisper-large-v3', - language='Russian', - task='transcribe', -) - -def pipeline_transcribe_with_whisper( - waveform: str, - pipeline: Pipeline, -) -> str: - return pipeline(waveform)['text'] - -def longform_transcribe_with_whisper( - waveform: str, - processor: WhisperProcessor, - model: WhisperForConditionalGeneration, - condition_on_prev_tokens: bool = False, -) -> str: - # https://github.com/huggingface/transformers/pull/27658 - inputs = processor( - waveform, - return_tensors="pt", - truncation=False, - padding="longest", - return_attention_mask=True, - sampling_rate=16_000 - ).to("cuda") - result = model.generate( - **inputs, - condition_on_prev_tokens=condition_on_prev_tokens, - temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0), - logprob_threshold=-1.0, - compression_ratio_threshold=1.35, - return_timestamps=True, - language='<|ru|>', - task='transcribe', - ) - return whisper_processor.batch_decode(result, skip_special_tokens=True)[0] - -input_dir = Path('/home/oleg/pisets_test_set/') -output_dir = Path('/home/oleg/pisets_test_results/') - -for audio_path in input_dir.glob('*.wav'): - - if (srt_path := audio_path.with_suffix('.srt')).is_file(): - truth = ' '.join([sub.text for sub in pysrt.open(srt_path)]) - else: - truth = open(audio_path.with_suffix('.txt')).read() - - long_waveform, _ = librosa.load(audio_path, sr=16_000) - print(f'{audio_path.stem} {len(long_waveform) / 16_000} sec') - - pred = pipeline_transcribe_with_whisper(long_waveform, recognizer) - print('pipeline', MultipleTextsAlignment.from_strings(truth, pred).wer()) - with open(output_dir / f'{audio_path.stem}_only_whisper_pipeline.txt', 'w') as f: - f.write(pred) - - pred = longform_transcribe_with_whisper(long_waveform, whisper_processor, recognizer.model) - print('longform', MultipleTextsAlignment.from_strings(truth, pred).wer()) - with open(output_dir / f'{audio_path.stem}_only_whisper_longform.txt', 'w') as f: - f.write(pred) - - pred = longform_transcribe_with_whisper(long_waveform, whisper_processor, recognizer.model, condition_on_prev_tokens=True) - print('longform conditioned', MultipleTextsAlignment.from_strings(truth, pred).wer()) - with open(output_dir / f'{audio_path.stem}_only_whisper_longform_conditioned.txt', 'w') as f: - f.write(pred) \ No newline at end of file diff --git a/evaluation/get_pisets_results.py b/evaluation/get_pisets_results.py deleted file mode 100644 index 825e540..0000000 --- a/evaluation/get_pisets_results.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -from pathlib import Path -import json -import dataclasses - -import librosa -import pysrt -import numpy as np -import pandas as pd -from datasets import load_dataset, Audio - -from IPython.display import clear_output -from wav_io.wav_io import load_sound -from asr.asr import * -from asr.comparison import * - -segmenter_no_lm = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos') -segmenter_lm = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm') -vad = initialize_model_for_speech_classification() -asr_whisper_large_v2 = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v2') -asr_whisper_large_v3 = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3') - -max_len = None - -class EnhancedJSONEncoder(json.JSONEncoder): - def default(self, o): - if dataclasses.is_dataclass(o): - return dataclasses.asdict(o) - return super().default(o) - -input_dir = Path('/home/oleg/pisets_test_set/') -output_dir = Path('/home/oleg/pisets_test_results/') - -for audio_path in input_dir.glob('*.wav'): - # if (srt_path := audio_path.with_suffix('.srt')).is_file(): - # truth = ' '.join([sub.text for sub in pysrt.open(srt_path)]) - # else: - # with open(audio_path.with_suffix('.txt')) as f: - # truth = f.read() - - name = audio_path.stem - - waveform, _ = librosa.load(audio_path, sr=16_000) - - for mode_name, args, kwargs in ( - ('nolm_whisperV3_1_20', (segmenter_no_lm, vad, asr_whisper_large_v3), dict(min_segment_size=1, max_segment_size=20)), - ('lm_whisperV2_1_20', (segmenter_lm, vad, asr_whisper_large_v2), dict(min_segment_size=1, max_segment_size=20)), - ('lm_whisperV3_15_25_stretch', (segmenter_lm, vad, asr_whisper_large_v3), dict(min_segment_size=15, max_segment_size=25, stretch=(3, 4))), - ('lm_whisperV3_1_20_stretch', (segmenter_lm, vad, asr_whisper_large_v3), dict(min_segment_size=1, max_segment_size=20, stretch=(3, 4))), - ('lm_whisperV3_1_30_stretch', (segmenter_lm, vad, asr_whisper_large_v3), dict(min_segment_size=1, max_segment_size=30, stretch=(3, 4))), - ): - print(name, mode_name) - output = transcribe(waveform[:max_len], *args, **kwargs) - with open(output_dir / f'{name}_{mode_name}.json', 'w') as f: - json.dump(output, f, cls=EnhancedJSONEncoder) - # print(' '.join(x.transcription for x in output)) \ No newline at end of file From 04bd01a534351faf1772eadaa933d8e38f50839a Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sat, 30 Nov 2024 21:02:42 +0300 Subject: [PATCH 19/24] renaming files --- evaluation/calc_metrics.py | 0 evaluation/{eval.py => make_predictions.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 evaluation/calc_metrics.py rename evaluation/{eval.py => make_predictions.py} (100%) diff --git a/evaluation/calc_metrics.py b/evaluation/calc_metrics.py new file mode 100644 index 0000000..e69de29 diff --git a/evaluation/eval.py b/evaluation/make_predictions.py similarity index 100% rename from evaluation/eval.py rename to evaluation/make_predictions.py From 1f3ac6917ab5aa87a8a15c837bfd9bb62f356124 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sun, 1 Dec 2024 12:38:46 +0300 Subject: [PATCH 20/24] whisper sequence score --- asr/comparison.py | 19 +- asr/lm.py | 42 ++ asr/whisper_scores.py | 179 +++++++ evaluation/Uncertainty.ipynb | 496 ++++++++++++++++++ evaluation/calc_metrics.ipynb | 206 ++++++++ evaluation/calc_metrics.py | 0 evaluation/make_predictions.py | 43 +- .../make_predictions_with_whisper_scores.py | 62 +++ evaluation/my_pisets_results_summarize.ipynb | 492 ----------------- 9 files changed, 1043 insertions(+), 496 deletions(-) create mode 100644 asr/lm.py create mode 100644 asr/whisper_scores.py create mode 100644 evaluation/Uncertainty.ipynb create mode 100644 evaluation/calc_metrics.ipynb delete mode 100644 evaluation/calc_metrics.py create mode 100644 evaluation/make_predictions_with_whisper_scores.py delete mode 100644 evaluation/my_pisets_results_summarize.ipynb diff --git a/asr/comparison.py b/asr/comparison.py index c41b3eb..38b1c85 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -74,6 +74,23 @@ def from_text(cls, text: str, dash_as_separator: bool = True) -> TokenizedText: for t in razdel.tokenize(text) ] return TokenizedText(text=orig_text, tokens=tokens) + + @classmethod + def concatenate(cls, texts: list[TokenizedText], sep: str = ' ') -> TokenizedText: + result_text = '' + result_tokens = [] + for i, tokenized_text in enumerate(texts): + shift = len(result_text) + result_text += tokenized_text.text + for token in tokenized_text.tokens: + token = copy.copy(token) + token.start += shift + token.stop += shift + result_tokens.append(token) + if i < len(texts) - 1: + result_text += sep + + return TokenizedText(text=result_text, tokens=result_tokens) @dataclass class WordLevelMatch: @@ -227,7 +244,7 @@ class MultipleTextsAlignment: We can see a single "replace" operation from 3 words to 2 words. However, in WER metric this will be considered as two "replace" and one "delete" operation. To calculate WER correctly, - use `.wer` property. + use `.wer` method. """ text1: TokenizedText text2: TokenizedText diff --git a/asr/lm.py b/asr/lm.py new file mode 100644 index 0000000..e1c76e2 --- /dev/null +++ b/asr/lm.py @@ -0,0 +1,42 @@ +from typing import Literal + +import numpy as np + +import torch.nn.functional as F +from transformers import AutoModelForCausalLM, AutoTokenizer +from transformers.tokenization_utils_fast import PreTrainedTokenizerBase +from transformers.generation.utils import GenerationMixin + +class SequenceScore: + """ + Calculates a sequence score for a text from an autoregressive LM. + """ + def __init__( + self, + name: str | None = 'ai-forever/rugpt3large_based_on_gpt2', + tokenizer: PreTrainedTokenizerBase | None = None, + model: GenerationMixin | None = None, + ): + if name is not None: + assert not tokenizer and not model + # https://stackoverflow.com/a/75242984 + tokenizer = AutoTokenizer.from_pretrained(name, add_bos_token=True) + model = AutoModelForCausalLM.from_pretrained(name) + else: + assert tokenizer and model + + + self.tokenizer = tokenizer + self.model = model + + def __call__(self, text: str) -> int: + inputs = self.tokenizer([text], return_tensors='pt') + logits = self.model(**inputs, return_dict=True).logits[:, :-1] + targets = inputs['input_ids'][:, 1:] + logloss = F.cross_entropy(input=logits.transpose(1, 2), target=targets) + logloss = logloss.cpu().detach().numpy() + + if np.isnan(logloss): + return 0 # TODO why happens? + + return -logloss \ No newline at end of file diff --git a/asr/whisper_scores.py b/asr/whisper_scores.py new file mode 100644 index 0000000..d177edd --- /dev/null +++ b/asr/whisper_scores.py @@ -0,0 +1,179 @@ +from typing import Any + +import torch +import numpy as np +from transformers.models.whisper.tokenization_whisper import bytes_to_unicode +from transformers import ( + AutomaticSpeechRecognitionPipeline, + WhisperFeatureExtractor, + WhisperTokenizer, + WhisperTokenizerFast, + WhisperForConditionalGeneration +) + +from .comparison import TokenizedText + + +def whisper_pipeline_transcribe_with_word_scores( + waveform: np.ndarray, + recognizer: AutomaticSpeechRecognitionPipeline, +) -> tuple[TokenizedText, list[list[str]], list[list[float]]]: + """ + A wrapper around `.whisper_transcribe_with_word_scores()` to use a pipeline. + Example: + + ``` + import librosa + from asr.asr import initialize_model_for_speech_recognition + waveform, _ = librosa.load('tests/testdata/test_sound_ru.wav', sr=None) + pipeline = initialize_model_for_speech_recognition() + whisper_pipeline_transcribe_with_word_scores(waveform, pipeline) + ``` + """ + return whisper_transcribe_with_word_scores( + waveform, + recognizer.feature_extractor, + recognizer.tokenizer, + recognizer.model, + recognizer._forward_params, # lang, task + ) + + +def whisper_transcribe_with_word_scores( + waveform: np.ndarray, + feature_extractor: WhisperFeatureExtractor, + tokenizer: WhisperTokenizer | WhisperTokenizerFast, + model: WhisperForConditionalGeneration, + generate_kwargs: dict[str, Any], +) -> tuple[TokenizedText, list[list[str]], list[list[float]]]: + """ + Transcribes the audio with Whisper and returns: + - the resulting text tokenized into words + - a list of tokens for each word + - a list of token scores for each word + + Example: + ``` + import librosa + waveform, _ = librosa.load('tests/testdata/test_sound_ru.wav', sr=None) + recognizer = pipeline('automatic-speech-recognition', model='openai/whisper-large-v3') + whisper_transcribe_with_word_scores( + waveform, + recognizer.feature_extractor, + recognizer.tokenizer, + recognizer.model, + {'language': '<|ru|>', 'task': 'transcribe'}, # or `recognizer._forward_params` + ) + + >>> ( + TokenizedText( + text=' нейронные сети это хорошо.', + tokens=[ + Substring(start=1, stop=10, text='нейронные', is_punct=False), + Substring(start=11, stop=15, text='сети', is_punct=False), + Substring(start=16, stop=19, text='это', is_punct=False), + Substring(start=20, stop=26, text='хорошо', is_punct=False), + Substring(start=26, stop=27, text='.', is_punct=True) + ] + ), + [[' ней', 'рон', 'ные'], [' с', 'ети'], [' это'], [' хорошо']], + [[-0.61, -6.80e-05, -0.00], [-8.82e-05, -2.41e-05], [-0.57], [-0.00]] + ) + ``` + """ + assert model.config.model_type == 'whisper' + + inputs = feature_extractor( + waveform, + return_tensors='pt', + sampling_rate=16_000, + ).to(model.device, model.dtype) + result = model.generate( + **inputs, + **generate_kwargs, + return_dict_in_generate=True, + return_token_timestamps=True, + ) + + # convert token ids and logits to numpy + token_ids = result['sequences'][0].cpu().numpy() + logits = torch.nn.functional.log_softmax(torch.stack(result['scores']), dim=-1).cpu().numpy() + + # skip start special tokens to align with logits + token_ids = token_ids[-len(logits):] + + # skip all special tokens + is_special = np.array([id in tokenizer.all_special_ids for id in token_ids]) + token_ids = token_ids[~is_special] + logits = logits[~is_special] + + score_per_token = np.array([float(l[0, token_id]) for token_id, l in zip(token_ids, logits)]) + + # reproducing whisper bpe decoding + byte_decoder = {v: k for k, v in bytes_to_unicode().items()} + bytes_list_per_token = [ + [byte_decoder[x] for x in bytes_str] + for bytes_str in tokenizer.convert_ids_to_tokens(token_ids) + ] + + # searching for token positions in the text + token_end_positions = [] + for i in range(len(bytes_list_per_token)): + concatenated_bytes = sum(bytes_list_per_token[:i + 1], []) + try: + text = bytearray(concatenated_bytes).decode('utf-8', errors='strict') + token_end_positions.append(len(text)) + except UnicodeDecodeError: + token_end_positions.append(None) # not a full utf-8 charachter + + assert text == tokenizer.decode(token_ids, clean_up_tokenization_spaces=False) + + # cleaning up tokenization spaces, shifting token_end_positions + # (see .clean_up_tokenization() in PreTrainedTokenizerBase) + if tokenizer.clean_up_tokenization_spaces: + for replace_from in [" .", " ?", " !", " ,", " ' ", " n't", " 'm", " 's", " 've", " 're"]: + replace_to = replace_from.strip() + while (start_pos := text.find(replace_from)) != -1: + delta_len = len(replace_to) - len(replace_from) + text = text[:start_pos] + replace_to + text[start_pos + len(replace_from):] + token_end_positions = [ + ( + token_end_pos + if token_end_pos <= start_pos + else token_end_pos + delta_len + ) + for token_end_pos in token_end_positions + ] + + assert text == tokenizer.decode(token_ids) + + # tokenizing the text + tokenized_text = TokenizedText.from_text(text) + + # matching words and tokens + tokens_range_per_word = [] + for word in tokenized_text.get_words(): + first_token_idx = None # first token of the word, inclusive + for token_idx, token_end_pos in enumerate(token_end_positions): + if token_end_pos is None: + continue + if token_end_pos > word.start and first_token_idx is None: + first_token_idx = token_idx + if token_end_pos >= word.stop: + break + tokens_range_per_word.append((first_token_idx, token_idx + 1)) + + tokens_per_word = [ + [ + bytearray(b).decode('utf-8', errors='replace') + for b in bytes_list_per_token[start_token_idx:end_token_idx] + ] + for start_token_idx, end_token_idx in tokens_range_per_word + ] + + token_scores_per_word = [ + list(score_per_token[start_token_idx:end_token_idx]) + for start_token_idx, end_token_idx in tokens_range_per_word + ] + + return tokenized_text, tokens_per_word, token_scores_per_word \ No newline at end of file diff --git a/evaluation/Uncertainty.ipynb b/evaluation/Uncertainty.ipynb new file mode 100644 index 0000000..67dac5c --- /dev/null +++ b/evaluation/Uncertainty.ipynb @@ -0,0 +1,496 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "from itertools import combinations\n", + "from typing import Any\n", + "import dataclasses\n", + "\n", + "from tqdm.auto import tqdm\n", + "import pandas as pd\n", + "import numpy as np\n", + "import torch\n", + "from datasets import load_dataset, Audio\n", + "from transformers import (\n", + " AutoModelForCausalLM, AutoTokenizer, pipeline, Pipeline,\n", + " WhisperProcessor, WhisperForConditionalGeneration\n", + ")\n", + "import pysrt\n", + "from IPython.display import clear_output\n", + "import IPython.display\n", + "import librosa\n", + "\n", + "from asr.asr import (\n", + " initialize_model_for_speech_segmentation,\n", + " initialize_model_for_speech_classification,\n", + " initialize_model_for_speech_recognition,\n", + " transcribe\n", + ")\n", + "from asr.lm import SequenceScore\n", + "from asr.comparison import TokenizedText, MultipleTextsAlignment, filter_correction_suggestions\n", + "from asr.whisper_scores import whisper_pipeline_transcribe_with_word_scores" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = (\n", + " load_dataset('dangrebenkin/long_audio_youtube_lectures')\n", + " .cast_column('audio', Audio(sampling_rate=16_000))\n", + " ['train']\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sample = dataset[2]\n", + "waveform = sample['audio']['array']\n", + "sample['name']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "segmenter = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos')\n", + "whisper_pipeline = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3')\n", + "\n", + "results = transcribe(\n", + " waveform,\n", + " segmenter=segmenter,\n", + " voice_activity_detector=lambda audio: [{'score': 1, 'label': 'Speech'}],\n", + " asr=lambda audio: {'text': 'none'},\n", + " min_segment_size=1,\n", + " max_segment_size=20,\n", + ")\n", + "\n", + "tokenized_segments = []\n", + "scores_per_word = []\n", + "\n", + "for segment in tqdm(results):\n", + " waveform_segment = waveform[int(segment.start * 16_000):int(segment.end * 16_000)]\n", + " tokenized_text_for_segment, _, scores_for_segment = (\n", + " whisper_pipeline_transcribe_with_word_scores(waveform_segment, whisper_pipeline)\n", + " )\n", + " tokenized_segments.append(tokenized_text_for_segment)\n", + " scores_per_word += scores_for_segment\n", + "\n", + "tokenized_text = TokenizedText.concatenate(tokenized_segments)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from transformers.models.whisper.tokenization_whisper import bytes_to_unicode\n", + "\n", + "\n", + "feature_extractor = whisper_pipeline.feature_extractor\n", + "tokenizer = whisper_pipeline.tokenizer\n", + "model = whisper_pipeline.model\n", + "generate_kwargs = whisper_pipeline._forward_params\n", + "\n", + "inputs = feature_extractor(\n", + " waveform_segment,\n", + " return_tensors='pt',\n", + " sampling_rate=16_000,\n", + ").to(model.device, model.dtype)\n", + "result = model.generate(\n", + " **inputs,\n", + " **generate_kwargs,\n", + " return_dict_in_generate=True,\n", + " return_token_timestamps=True,\n", + ")\n", + "\n", + "# convert token ids and logits to numpy\n", + "token_ids = result['sequences'][0].cpu().numpy()\n", + "logits = torch.nn.functional.log_softmax(torch.stack(result['scores']), dim=-1).cpu().numpy()\n", + "\n", + "# skip start special tokens to align with logits\n", + "token_ids = token_ids[-len(logits):]\n", + "\n", + "# skip all special tokens\n", + "is_special = np.array([id in tokenizer.all_special_ids for id in token_ids])\n", + "token_ids = token_ids[~is_special]\n", + "logits = logits[~is_special]\n", + "\n", + "score_per_token = np.array([float(l[0, token_id]) for token_id, l in zip(token_ids, logits)])\n", + "\n", + "# reproducing whisper bpe decoding\n", + "byte_decoder = {v: k for k, v in bytes_to_unicode().items()}\n", + "bytes_list_per_token = [\n", + " [byte_decoder[x] for x in bytes_str]\n", + " for bytes_str in tokenizer.convert_ids_to_tokens(token_ids)\n", + "]\n", + "\n", + "# searching for token positions in the text\n", + "token_end_positions = []\n", + "for i in range(len(bytes_list_per_token)):\n", + " concatenated_bytes = sum(bytes_list_per_token[:i + 1], [])\n", + " try:\n", + " text = bytearray(concatenated_bytes).decode('utf-8', errors='strict')\n", + " token_end_positions.append(len(text))\n", + " except UnicodeDecodeError:\n", + " token_end_positions.append(None) # not a full utf-8 charachter\n", + "\n", + "assert text == tokenizer.decode(token_ids, clean_up_tokenization_spaces=False)\n", + "\n", + "# cleaning up tokenization spaces, shifting token_end_positions\n", + "# (see .clean_up_tokenization() in PreTrainedTokenizerBase)\n", + "if tokenizer.clean_up_tokenization_spaces:\n", + " for replace_from in [\" .\", \" ?\", \" !\", \" ,\", \" ' \", \" n't\", \" 'm\", \" 's\", \" 've\", \" 're\"]:\n", + " replace_to = replace_from.strip()\n", + " while (start_pos := text.find(replace_from)) != -1:\n", + " delta_len = len(replace_to) - len(replace_from)\n", + " text = text[:start_pos] + replace_to + text[start_pos + len(replace_from):]\n", + " token_end_positions = [\n", + " (\n", + " token_end_pos\n", + " if token_end_pos <= start_pos\n", + " else token_end_pos + delta_len\n", + " )\n", + " for token_end_pos in token_end_positions\n", + " ]\n", + "\n", + " assert text == tokenizer.decode(token_ids)\n", + "\n", + "# tokenizing the text\n", + "tokenized_text = TokenizedText.from_text(text)\n", + "\n", + "# matching words and tokens\n", + "tokens_range_per_word = []\n", + "for word in tokenized_text.get_words():\n", + " first_token_idx = None # first token of the word, inclusive\n", + " for token_idx, token_end_pos in enumerate(token_end_positions):\n", + " if token_end_pos is None:\n", + " continue\n", + " if token_end_pos > word.start and first_token_idx is None:\n", + " first_token_idx = token_idx\n", + " if token_end_pos >= word.stop:\n", + " break\n", + " tokens_range_per_word.append((first_token_idx, token_idx + 1))\n", + "\n", + "tokens_per_word = [\n", + " [\n", + " bytearray(b).decode('utf-8', errors='replace')\n", + " for b in bytes_list_per_token[start_token_idx:end_token_idx]\n", + " ]\n", + " for start_token_idx, end_token_idx in tokens_range_per_word\n", + "]\n", + "\n", + "token_scores_per_word = [\n", + " list(score_per_token[start_token_idx:end_token_idx])\n", + " for start_token_idx, end_token_idx in tokens_range_per_word\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bytearray(sum(bytes_list_per_token, [])).decode('utf-8', errors='strict')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "text" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "tokenizer.decode(token_ids, skip_special_tokens=False, clean_up_tokenization_spaces=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "output_dir = Path('/home/oleg/pisets_test_results_with_scores')\n", + "output_dir.mkdir(parents=True, exist_ok=True)\n", + "\n", + "filepath = output_dir / f'{sample[\"name\"]} Pisets WhisperV3 no-VAD (segments 1s-20s) with scores.json'\n", + "\n", + "with open(filepath, 'w') as f:\n", + " json.dump({\n", + " 'tokenized_text': dataclasses.asdict(tokenized_text),\n", + " 'scores_per_word': scores_per_word,\n", + " }, f, ensure_ascii=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!cat \"/home/oleg/pisets_test_results_with_scores/savvateev Pisets WhisperV3 no-VAD (segments 1s-20s) with scores.json\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def get_all_subsets(elements: list[Any]):\n", + " \"\"\"\n", + " Returns all subsets of a list.\n", + " ```\n", + " get_all_subsets([1, 2, 3])\n", + " >>> [(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]\n", + " ```\n", + " \"\"\"\n", + " return sum((\n", + " [list(x) for x in combinations(elements, r)]\n", + " for r in range(len(elements) + 1)\n", + " ), [])\n", + "\n", + "base = transcriptions['galore']['whisperV3_long_segments_ru']\n", + "additional = transcriptions['galore']['w2v2_golos_lm']\n", + "truth = transcriptions['galore']['truth']\n", + "\n", + "MultipleTextsAlignment.from_strings(truth, base).wer()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "is_uncertain = MultipleTextsAlignment.from_strings(base, additional).get_uncertainty_mask()\n", + "print('Uncertain words ratio', is_uncertain.mean())\n", + "MultipleTextsAlignment.from_strings(truth, base).wer(uncertainty_mask=is_uncertain)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "alignment = MultipleTextsAlignment.from_strings(base, additional)\n", + "orig_indices_to_resolve = filter_correction_suggestions(alignment, skip_word_form_change=False)\n", + "indices_to_resolve = orig_indices_to_resolve.copy()\n", + "indices_accepted = []\n", + "\n", + "# print(alignment.substitute(show_in_braces=indices_to_resolve))\n", + "\n", + "depth = 2\n", + "\n", + "context_before = 100\n", + "context_after = 100\n", + "\n", + "while len(indices_to_resolve):\n", + " print(f'{len(indices_to_resolve)} indices remaining')\n", + "\n", + " indices = indices_to_resolve[:depth]\n", + "\n", + " variants: list[list[int]] = get_all_subsets(indices)\n", + "\n", + " scores = {}\n", + "\n", + " for indices_to_consider in get_all_subsets(indices):\n", + " text = alignment.substitute(replace=indices_accepted + indices_to_consider)\n", + "\n", + " start_idx = alignment.matches[indices[0]].char_start1\n", + " end_idx = alignment.matches[indices[-1]].char_end1 + len(text) - len(alignment.text1.text)\n", + "\n", + " start_idx -= context_before\n", + " end_idx += context_after\n", + "\n", + " start_idx = np.clip(start_idx, 0, len(text))\n", + " end_idx = np.clip(end_idx, 0, len(text))\n", + "\n", + " text = text[start_idx:end_idx]\n", + "\n", + " scores[tuple(indices_to_consider)] = {\n", + " 'score': sequence_score(text),\n", + " 'text' : text\n", + " }\n", + "\n", + " print([x['score'] for x in scores.values()])\n", + "\n", + " best_option = max(scores, key=lambda k: scores[k]['score'])\n", + "\n", + " should_accept_index = indices[0] in best_option\n", + "\n", + " if should_accept_index:\n", + " indices_accepted.append(indices[0])\n", + " \n", + " indices_to_resolve = indices_to_resolve[1:]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "corrected = alignment.substitute(replace=indices_accepted)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MultipleTextsAlignment.from_strings(truth, corrected).wer()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "is_uncertain = MultipleTextsAlignment.from_strings(base, corrected).get_uncertainty_mask()\n", + "print('Uncertain words ratio', is_uncertain.mean())\n", + "MultipleTextsAlignment.from_strings(truth, base).wer(uncertainty_mask=is_uncertain)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "alignment = MultipleTextsAlignment.from_strings(base, additional)\n", + "\n", + "print(alignment.substitute(\n", + " show_in_braces=[i for i, op in enumerate(alignment.matches) if not op.is_equal]\n", + "))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "alignment = MultipleTextsAlignment.from_strings(truth, base)\n", + "\n", + "print(alignment.substitute(\n", + " show_in_braces=[i for i, op in enumerate(alignment.matches) if not op.is_equal]\n", + "))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "alignment = MultipleTextsAlignment.from_strings(base, corrected)\n", + "\n", + "print(alignment.substitute(\n", + " show_in_braces=filter_correction_suggestions(alignment, skip_word_form_change=False)\n", + "))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# print(alignment.substitute(\n", + "# show_in_braces=orig_indices_to_resolve,\n", + "# pref_second=indices_accepted,\n", + "# pref_first=set(orig_indices_to_resolve) - set(indices_accepted),\n", + "# ))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "'''\n", + "I have two speech recognition models (the first model is usually better) and compare their predictions. In the following text, the disagreement between models is highlighted in braces.\n", + "\n", + "- {aaa|bbb} means that the second model wants to replace \"aaa\" with \"bbb\"\n", + "- {+xx} means that the second model wants to insert \"xx\" into the first model predictions\n", + "- {yy} means that the second model wants to remove \"yy\" from the first model predictions\n", + "\n", + "Based on linguistic knowledge and common sense, please resolve the disagreement and write the final transcription without braces.\n", + "\n", + "The text:\n", + "'''" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/evaluation/calc_metrics.ipynb b/evaluation/calc_metrics.ipynb new file mode 100644 index 0000000..5a81956 --- /dev/null +++ b/evaluation/calc_metrics.ipynb @@ -0,0 +1,206 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "import pandas as pd\n", + "from datasets import load_dataset\n", + "\n", + "from asr.comparison import MultipleTextsAlignment\n", + "\n", + "dataset = load_dataset('dangrebenkin/long_audio_youtube_lectures')['train']\n", + "name_to_transcription = dict(zip(dataset['name'], dataset['transcription']))\n", + "\n", + "results_list = []\n", + "for filepath in Path('/home/oleg/pisets_test_results').glob('*.json'):\n", + " data = json.loads(filepath.read_text())\n", + " for pipeline_name, transcription in data['transcriptions'].items():\n", + " results_list.append({\n", + " 'audio_name': data['audio_name'],\n", + " 'pipeline_name': pipeline_name,\n", + " 'transcription': transcription,\n", + " })\n", + "\n", + "results = pd.DataFrame(results_list)\n", + "\n", + "results['alignment'] = results.apply(\n", + " lambda row: MultipleTextsAlignment.from_strings(\n", + " name_to_transcription[row['audio_name']],\n", + " row['transcription']\n", + " ),\n", + " axis='columns'\n", + ")\n", + "results['wer'] = results['alignment'].apply(lambda al: al.wer()['wer'])" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
audio_nameharvardkolodezevlankovsavvateevtuberculosiszaliznyakzhirinovsky
pipeline_name
Baseline Whisper longform0.010929NaNNaNNaNNaN0.158086NaN
Baseline Whisper pipeline0.0455370.1552280.1473540.1924400.1995010.1316170.115655
Pisets WhisperV3 (segments 1s-20s)0.0159380.1293570.0875130.216986NaN0.1167510.060306
Pisets WhisperV3 stretched (segments 1s-20s)0.0377960.1149840.1099970.348061NaN0.1392310.072697
W2V2 Golos LM0.1498180.2716490.3168450.629357NaN0.2505440.261875
\n", + "
" + ], + "text/plain": [ + "audio_name harvard kolodezev lankov \\\n", + "pipeline_name \n", + "Baseline Whisper longform 0.010929 NaN NaN \n", + "Baseline Whisper pipeline 0.045537 0.155228 0.147354 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.015938 0.129357 0.087513 \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.037796 0.114984 0.109997 \n", + "W2V2 Golos LM 0.149818 0.271649 0.316845 \n", + "\n", + "audio_name savvateev tuberculosis \\\n", + "pipeline_name \n", + "Baseline Whisper longform NaN NaN \n", + "Baseline Whisper pipeline 0.192440 0.199501 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.216986 NaN \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.348061 NaN \n", + "W2V2 Golos LM 0.629357 NaN \n", + "\n", + "audio_name zaliznyak zhirinovsky \n", + "pipeline_name \n", + "Baseline Whisper longform 0.158086 NaN \n", + "Baseline Whisper pipeline 0.131617 0.115655 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.116751 0.060306 \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.139231 0.072697 \n", + "W2V2 Golos LM 0.250544 0.261875 " + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results.pivot_table(values='wer', index='pipeline_name', columns='audio_name')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/evaluation/calc_metrics.py b/evaluation/calc_metrics.py deleted file mode 100644 index e69de29..0000000 diff --git a/evaluation/make_predictions.py b/evaluation/make_predictions.py index e06e2b5..28d17c8 100644 --- a/evaluation/make_predictions.py +++ b/evaluation/make_predictions.py @@ -38,7 +38,7 @@ def __init__(self, predictions_name: str): ) def __call__(self, waveform: np.ndarray) -> dict[str, str]: - return self.whisper_pipeline(waveform)['text'] + return {self.predictions_name: self.whisper_pipeline(waveform)['text']} class TranscribeWhisperLongform(TranscribeWhisperPipeline): @@ -74,7 +74,8 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: language='<|ru|>', task='transcribe', ) - return self.whisper_processor.batch_decode(result, skip_special_tokens=True)[0] + text = self.whisper_processor.batch_decode(result, skip_special_tokens=True)[0] + return {self.predictions_name: text} @dataclass @@ -115,7 +116,7 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: asr=( self.asr if self.asr != 'skip' - else (lambda audio: {'text': ''}) + else (lambda audio: {'text': 'none'}) ), min_segment_size=self.min_segment_size, max_segment_size=self.max_segment_size, @@ -130,7 +131,21 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: if self.asr_stretched_predictions_name is not None: results[self.asr_stretched_predictions_name] = ' '.join([s.transcription_stretched for s in outputs]) return results + + +@dataclass +class TranscribeNoisy: + """ + Transcribe with a specified signal-to-noise ratio + """ + snr: float + transcriber: Callable + + def __call__(self, waveform: np.ndarray) -> dict[str, str]: + # TODO augment + return self.transcriber(waveform) + # defining transcribers without instantiating them all at once to save GPU memory transcribers = { @@ -196,6 +211,28 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: ), } +# for snr in [1, 2, 3, 4, 5]: +# transcribers[f'Whisper longform SNR={snr}'] = lambda: TranscribeNoisy( +# snr=snr, +# transcriber=TranscribeWhisperLongform( +# predictions_name=f'Baseline Whisper longform SNR={snr}', +# condition_on_prev_tokens=False, +# ), +# ) +# transcribers[f'Pisets (segments 1s-20s) SNR={snr}'] = lambda: TranscribeNoisy( +# snr=snr, +# transcriber=TranscribePisets( +# segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), +# vad=initialize_model_for_speech_classification(), +# asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'), +# min_segment_size=1, +# max_segment_size=20, +# segmenter_predictions_name=f'W2V2 Golos LM SNR={snr}', +# asr_predictions_name=f'Pisets WhisperV3 (segments 1s-20s) SNR={snr}', +# ), +# ) + + dataset = ( load_dataset('dangrebenkin/long_audio_youtube_lectures') .cast_column('audio', Audio(sampling_rate=16_000)) diff --git a/evaluation/make_predictions_with_whisper_scores.py b/evaluation/make_predictions_with_whisper_scores.py new file mode 100644 index 0000000..1c6aa78 --- /dev/null +++ b/evaluation/make_predictions_with_whisper_scores.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path +import dataclasses + +from datasets import load_dataset, Audio +from tqdm.auto import tqdm + +from asr.asr import ( + initialize_model_for_speech_segmentation, + initialize_model_for_speech_recognition, + transcribe +) +from asr.comparison import TokenizedText +from asr.whisper_scores import whisper_pipeline_transcribe_with_word_scores + + +dataset = ( + load_dataset('dangrebenkin/long_audio_youtube_lectures') + .cast_column('audio', Audio(sampling_rate=16_000)) + ['train'] +) + +output_dir = Path('/home/oleg/pisets_test_results_with_scores') +output_dir.mkdir(parents=True, exist_ok=True) + +segmenter = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos') +whisper_pipeline = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3') + +for sample in dataset: + print(sample['name']) + + waveform = sample['audio']['array'] + + results = transcribe( + waveform, + segmenter=segmenter, + voice_activity_detector=lambda audio: [{'score': 1, 'label': 'Speech'}], + asr=lambda audio: {'text': 'none'}, + min_segment_size=1, + max_segment_size=20, + ) + + tokenized_segments = [] + scores_per_word = [] + + for segment in tqdm(results, desc='whisper'): + waveform_segment = waveform[int(segment.start * 16_000):int(segment.end * 16_000)] + tokenized_text_for_segment, _, scores_for_segment = ( + whisper_pipeline_transcribe_with_word_scores(waveform_segment, whisper_pipeline) + ) + tokenized_segments.append(tokenized_text_for_segment) + scores_per_word += scores_for_segment + + tokenized_text = TokenizedText.concatenate(tokenized_segments) + + filepath = output_dir / f'{sample["name"]} Pisets WhisperV3 no-VAD (segments 1s-20s) with scores.json' + + with open(filepath, 'w') as f: + json.dump({ + 'tokenized_text': dataclasses.asdict(tokenized_text), + 'scores_per_word': scores_per_word, + }, f, ensure_ascii=False) \ No newline at end of file diff --git a/evaluation/my_pisets_results_summarize.ipynb b/evaluation/my_pisets_results_summarize.ipynb deleted file mode 100644 index aaf1b0e..0000000 --- a/evaluation/my_pisets_results_summarize.ipynb +++ /dev/null @@ -1,492 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "from pathlib import Path\n", - "\n", - "import pandas as pd\n", - "import pysrt\n", - "from IPython.display import clear_output\n", - "\n", - "from asr.comparison import MultipleTextsAlignment" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "input_dir = Path('/home/oleg/pisets_test_results')\n", - "\n", - "transcriptions = {}\n", - "\n", - "for audio_path in Path('/home/oleg/pisets_test_set/').glob('*.wav'):\n", - " \n", - " transcriptions[audio_path.stem] = {}\n", - "\n", - " if (srt_path := audio_path.with_suffix('.srt')).is_file():\n", - " truth = ' '.join([sub.text for sub in pysrt.open(srt_path)])\n", - " else:\n", - " with open(audio_path.with_suffix('.txt')) as f:\n", - " truth = f.read()\n", - " transcriptions[audio_path.stem]['truth'] = truth\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_only_whisper_pipeline.txt') as f:\n", - " transcriptions[audio_path.stem]['only_whisper_pipeline'] = f.read()\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_only_whisper_longform.txt') as f:\n", - " transcriptions[audio_path.stem]['only_whisper_longform'] = f.read()\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_only_whisper_longform_conditioned.txt') as f:\n", - " transcriptions[audio_path.stem]['only_whisper_longform_conditioned'] = f.read()\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_stretch_3_to_4.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['w2v2_golos_lm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", - " transcriptions[audio_path.stem]['whisperV3'] = ' '.join([x['transcription'] for x in outputs])\n", - " transcriptions[audio_path.stem]['whisperV3_stretch'] = ' '.join([x['transcription_stretched'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_nolm_whisperV3.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['w2v2_golos_nolm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", - " # transcriptions[audio_path.stem]['whisperV3_from_golos_nolm'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['w2v2_golos_nolm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", - " # transcriptions[audio_path.stem]['whisperV3_from_golos_nolm'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_new.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['whisperV3_ru'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_1_20.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['whisperV3_1-20_ru'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_1_30.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['whisperV3_1-30_ru'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_long_segments.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['whisperV3_long_segments'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV2.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['w2v2_golos_lm'] = ' '.join([x['transcription_from_segmenter'] for x in outputs])\n", - " transcriptions[audio_path.stem]['whisperV2'] = ' '.join([x['transcription'] for x in outputs])\n", - "\n", - " with open(input_dir / f'{audio_path.stem}_lm_whisperV3_long_segments_new.json') as f:\n", - " outputs = json.load(f)\n", - " transcriptions[audio_path.stem]['whisperV3_long_segments_ru'] = ' '.join([x['transcription'] for x in outputs])" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
modelonly_whisper_longformonly_whisper_longform_conditionedonly_whisper_pipelinew2v2_golos_lmw2v2_golos_nolmwhisperV2whisperV3whisperV3_1-20_ruwhisperV3_1-30_ruwhisperV3_long_segmentswhisperV3_long_segments_ruwhisperV3_ruwhisperV3_stretch
audio
galore0.1663670.3460290.1552280.2759610.2759610.1760690.1602590.1322310.1282790.1311530.1311530.1519940.173194
harvard0.0109290.0573770.0455370.1498180.1498180.0701280.0359740.0159380.0145720.0122950.0122950.0341530.064208
lankov0.1030790.1449330.1473540.3168450.3168450.1611900.1338640.0875130.0875130.1141470.1141470.1293670.167416
savvateev0.1742760.1929310.1924400.6053020.6053020.3190970.2793320.2169860.2272950.1757490.1757490.2704960.432499
tuberculosis0.1695760.2101000.1995010.2793020.2793020.2503120.1536780.1312340.1571070.1596010.1596010.1483790.177993
zaliznyak0.1580860.3132700.1316170.2451050.2451050.1823790.1682380.1167510.1073240.1279910.1269040.1577230.207759
zhirinovsky0.0433710.0772410.1156550.2544400.2544400.1379600.0945890.0603060.0681540.0652620.0652620.0855020.136720
\n", - "
" - ], - "text/plain": [ - "model only_whisper_longform only_whisper_longform_conditioned \\\n", - "audio \n", - "galore 0.166367 0.346029 \n", - "harvard 0.010929 0.057377 \n", - "lankov 0.103079 0.144933 \n", - "savvateev 0.174276 0.192931 \n", - "tuberculosis 0.169576 0.210100 \n", - "zaliznyak 0.158086 0.313270 \n", - "zhirinovsky 0.043371 0.077241 \n", - "\n", - "model only_whisper_pipeline w2v2_golos_lm w2v2_golos_nolm \\\n", - "audio \n", - "galore 0.155228 0.275961 0.275961 \n", - "harvard 0.045537 0.149818 0.149818 \n", - "lankov 0.147354 0.316845 0.316845 \n", - "savvateev 0.192440 0.605302 0.605302 \n", - "tuberculosis 0.199501 0.279302 0.279302 \n", - "zaliznyak 0.131617 0.245105 0.245105 \n", - "zhirinovsky 0.115655 0.254440 0.254440 \n", - "\n", - "model whisperV2 whisperV3 whisperV3_1-20_ru whisperV3_1-30_ru \\\n", - "audio \n", - "galore 0.176069 0.160259 0.132231 0.128279 \n", - "harvard 0.070128 0.035974 0.015938 0.014572 \n", - "lankov 0.161190 0.133864 0.087513 0.087513 \n", - "savvateev 0.319097 0.279332 0.216986 0.227295 \n", - "tuberculosis 0.250312 0.153678 0.131234 0.157107 \n", - "zaliznyak 0.182379 0.168238 0.116751 0.107324 \n", - "zhirinovsky 0.137960 0.094589 0.060306 0.068154 \n", - "\n", - "model whisperV3_long_segments whisperV3_long_segments_ru \\\n", - "audio \n", - "galore 0.131153 0.131153 \n", - "harvard 0.012295 0.012295 \n", - "lankov 0.114147 0.114147 \n", - "savvateev 0.175749 0.175749 \n", - "tuberculosis 0.159601 0.159601 \n", - "zaliznyak 0.127991 0.126904 \n", - "zhirinovsky 0.065262 0.065262 \n", - "\n", - "model whisperV3_ru whisperV3_stretch \n", - "audio \n", - "galore 0.151994 0.173194 \n", - "harvard 0.034153 0.064208 \n", - "lankov 0.129367 0.167416 \n", - "savvateev 0.270496 0.432499 \n", - "tuberculosis 0.148379 0.177993 \n", - "zaliznyak 0.157723 0.207759 \n", - "zhirinovsky 0.085502 0.136720 " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "wers = []\n", - "\n", - "for audio_name, t in transcriptions.items():\n", - " truth = t['truth']\n", - " for mode_name in set(t.keys()) - {'truth'}:\n", - " pred = t[mode_name]\n", - "\n", - " alignment = MultipleTextsAlignment.from_strings(truth, pred)\n", - " wers.append({'audio': audio_name, 'model': mode_name, 'wer': alignment.wer()['wer']}) # max_insertions=np.inf\n", - "\n", - " clear_output()\n", - "\n", - " df = pd.DataFrame(wers).pivot(index='audio', columns='model', values='wer')\n", - " display(df)" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
modelpisets 1-20pisets 1-30only_whisper_longformonly_whisper_pipeline
audio
galore0.1322310.1282790.1663670.155228
harvard0.0159380.0145720.0109290.045537
lankov0.0875130.0875130.1030790.147354
savvateev0.2169860.2272950.1742760.192440
tuberculosis0.1312340.1571070.1695760.199501
zaliznyak0.1167510.1073240.1580860.131617
zhirinovsky0.0603060.0681540.0433710.115655
\n", - "
" - ], - "text/plain": [ - "model pisets 1-20 pisets 1-30 only_whisper_longform \\\n", - "audio \n", - "galore 0.132231 0.128279 0.166367 \n", - "harvard 0.015938 0.014572 0.010929 \n", - "lankov 0.087513 0.087513 0.103079 \n", - "savvateev 0.216986 0.227295 0.174276 \n", - "tuberculosis 0.131234 0.157107 0.169576 \n", - "zaliznyak 0.116751 0.107324 0.158086 \n", - "zhirinovsky 0.060306 0.068154 0.043371 \n", - "\n", - "model only_whisper_pipeline \n", - "audio \n", - "galore 0.155228 \n", - "harvard 0.045537 \n", - "lankov 0.147354 \n", - "savvateev 0.192440 \n", - "tuberculosis 0.199501 \n", - "zaliznyak 0.131617 \n", - "zhirinovsky 0.115655 " - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df[['whisperV3_1-20_ru', 'whisperV3_1-30_ru', 'only_whisper_longform', 'only_whisper_pipeline']] \\\n", - " .rename(columns={'whisperV3_1-20_ru': 'pisets 1-20', 'whisperV3_1-30_ru': 'pisets 1-30'})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} From a03f7e701371fa5cbd41cff13931f350e573dd19 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sun, 1 Dec 2024 18:27:47 +0300 Subject: [PATCH 21/24] uncertainty methods and summarizing eval results --- asr/comparison.py | 52 +- asr/lm.py | 106 ++- evaluation/Uncertainty.ipynb | 496 -------------- evaluation/calc_metrics.ipynb | 648 +++++++++++++++++- evaluation/make_predictions.py | 14 +- .../make_predictions_with_whisper_scores.py | 6 +- evaluation/requirements.txt | 3 +- 7 files changed, 767 insertions(+), 558 deletions(-) delete mode 100644 evaluation/Uncertainty.ipynb diff --git a/asr/comparison.py b/asr/comparison.py index 38b1c85..84e5253 100644 --- a/asr/comparison.py +++ b/asr/comparison.py @@ -251,20 +251,31 @@ class MultipleTextsAlignment: matches: list[WordLevelMatch] @classmethod - def from_strings(cls, text1: str, text2: str) -> MultipleTextsAlignment: + def from_strings( + cls, + text1: str | TokenizedText, + text2: str | TokenizedText, + ) -> MultipleTextsAlignment: + if isinstance(text1, str): + text1 = TokenizedText.from_text(text1) + if isinstance(text2, str): + text2 = TokenizedText.from_text(text2) return MultipleTextsAlignment( - text1=(tokenized_text_1 := TokenizedText.from_text(text1)), - text2=(tokenized_text_2 := TokenizedText.from_text(text2)), + text1=text1, + text2=text2, matches=MultipleTextsAlignment.get_matches( - tokenized_text_1.get_words(), - tokenized_text_2.get_words(), + text1.get_words(), + text2.get_words(), ) ) - def get_uncertainty_mask(self) -> np.ndarray: + def get_uncertainty_mask(self, match_indices: list[int] | None = None) -> np.ndarray: is_certain = np.full(len(self.text1.get_words()), False) - for match in self.matches: - is_certain[match.start1:match.end1] = match.is_equal + for i, match in enumerate(self.matches): + if match_indices is not None and i not in match_indices: + is_certain[match.start1:match.end1] = True + else: + is_certain[match.start1:match.end1] = match.is_equal return ~is_certain def wer( @@ -329,12 +340,28 @@ def wer( results['certain_n_incorrect'] = certain_n_incorrect results['uncertain_n_correct'] = uncertain_n_correct results['uncertain_n_incorrect'] = uncertain_n_incorrect - results['certain_correctness_ratio'] = ( + results['certain_accuracy'] = ( certain_n_correct / (certain_n_correct + certain_n_incorrect) ) - results['uncertain_correctness_ratio'] = ( + results['uncertain_accuracy'] = ( uncertain_n_correct / (uncertain_n_correct + uncertain_n_incorrect) ) + results['precision'] = ( + results['uncertain_n_incorrect'] + / (results['uncertain_n_incorrect'] + results['uncertain_n_correct']) + ) + results['recall'] = ( + results['uncertain_n_incorrect'] + / (results['uncertain_n_incorrect'] + results['certain_n_incorrect']) + ) + results['uncertainty_ratio'] = uncertainty_mask.mean() + results['report'] = ( + f'uncertainty_ratio {results["uncertainty_ratio"]:.3f}' + f', certain acc. {results["certain_accuracy"]:.3f}' + f', uncertain acc. {results["uncertain_accuracy"]:.3f}' + f', precision {results["precision"]:.3f}' + f', recall {results["recall"]:.3f}' + ) return results @@ -666,7 +693,8 @@ def _should_keep( def filter_correction_suggestions( alignment: MultipleTextsAlignment, - skip_word_form_change: bool = False + skip_word_form_change: bool = False, + pbar: bool = True, ) -> list[int]: """ Arguments: @@ -683,7 +711,7 @@ def filter_correction_suggestions( NOTE: currently is adapted for Ru language """ return [ - i for i, op in enumerate(tqdm(alignment.matches, desc='Filtering suggestions')) + i for i, op in enumerate(tqdm(alignment.matches, desc='Filtering suggestions', disable=not pbar)) if not op.is_equal and _should_keep( alignment=alignment, diff=op, diff --git a/asr/lm.py b/asr/lm.py index e1c76e2..cd15983 100644 --- a/asr/lm.py +++ b/asr/lm.py @@ -1,12 +1,18 @@ -from typing import Literal +from itertools import combinations +from typing import Any +from tqdm import tqdm import numpy as np +import torch import torch.nn.functional as F from transformers import AutoModelForCausalLM, AutoTokenizer from transformers.tokenization_utils_fast import PreTrainedTokenizerBase from transformers.generation.utils import GenerationMixin +from asr.comparison import MultipleTextsAlignment + + class SequenceScore: """ Calculates a sequence score for a text from an autoregressive LM. @@ -28,15 +34,105 @@ def __init__( self.tokenizer = tokenizer self.model = model + self.model.eval() def __call__(self, text: str) -> int: inputs = self.tokenizer([text], return_tensors='pt') - logits = self.model(**inputs, return_dict=True).logits[:, :-1] - targets = inputs['input_ids'][:, 1:] - logloss = F.cross_entropy(input=logits.transpose(1, 2), target=targets) + with torch.no_grad(): + logits = self.model(**inputs, return_dict=True).logits[:, :-1] + targets = inputs['input_ids'][:, 1:] + logloss = F.cross_entropy(input=logits.transpose(1, 2), target=targets) + logloss = logloss.cpu().detach().numpy() if np.isnan(logloss): return 0 # TODO why happens? - return -logloss \ No newline at end of file + return -logloss + + +def get_all_subsets(elements: list[Any]): + """ + Returns all subsets of a list. + ``` + get_all_subsets([1, 2, 3]) + >>> [(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)] + ``` + """ + return sum(( + [list(x) for x in combinations(elements, r)] + for r in range(len(elements) + 1) + ), []) + +scorer = SequenceScore('ai-forever/rugpt3large_based_on_gpt2') + +def accept_suggestions_by_lm( + base_vs_additional: MultipleTextsAlignment, + suggestion_indices: list[int], + scorer: SequenceScore, + look_forward: int = 2, + context_before: int = 100, + context_after: int = 50, + pbar: bool = True, + verbose: bool = False, +) -> list[int]: + """ + When two predictions disagree, selects one that LM prefers. Returns suggestion_indices + when the second prediction (`base_vs_additional.text2`) was selected. + + TODO better docstring + """ + + orig_indices_to_resolve = suggestion_indices + indices_to_resolve = orig_indices_to_resolve.copy() + indices_accepted = [] + + if pbar: + _pbar = tqdm(total=len(indices_to_resolve)) + + while len(indices_to_resolve): + indices = indices_to_resolve[:look_forward] + + scores = {} + + for indices_to_consider in get_all_subsets(indices): + text = base_vs_additional.substitute(replace=indices_accepted + indices_to_consider) + + start_idx = base_vs_additional.matches[indices[0]].char_start1 + end_idx = ( + base_vs_additional.matches[indices[-1]].char_end1 + + len(text) - len(base_vs_additional.text1.text) + ) + + start_idx -= context_before + end_idx += context_after + + start_idx = np.clip(start_idx, 0, len(text)) + end_idx = np.clip(end_idx, 0, len(text)) + + text = text[start_idx:end_idx] + + scores[tuple(indices_to_consider)] = { + 'score': scorer(text), + # 'text' : text + } + + best_option = max(scores, key=lambda k: scores[k]['score']) + + if verbose: + print(f'[{len(indices_to_resolve)}] selected {best_option} from {scores}') + + should_accept_index = indices[0] in best_option + + if should_accept_index: + indices_accepted.append(indices[0]) + + indices_to_resolve = indices_to_resolve[1:] + + if pbar: + _pbar.update(1) + + if pbar: + _pbar.close() + + return indices_accepted \ No newline at end of file diff --git a/evaluation/Uncertainty.ipynb b/evaluation/Uncertainty.ipynb deleted file mode 100644 index 67dac5c..0000000 --- a/evaluation/Uncertainty.ipynb +++ /dev/null @@ -1,496 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "from pathlib import Path\n", - "from itertools import combinations\n", - "from typing import Any\n", - "import dataclasses\n", - "\n", - "from tqdm.auto import tqdm\n", - "import pandas as pd\n", - "import numpy as np\n", - "import torch\n", - "from datasets import load_dataset, Audio\n", - "from transformers import (\n", - " AutoModelForCausalLM, AutoTokenizer, pipeline, Pipeline,\n", - " WhisperProcessor, WhisperForConditionalGeneration\n", - ")\n", - "import pysrt\n", - "from IPython.display import clear_output\n", - "import IPython.display\n", - "import librosa\n", - "\n", - "from asr.asr import (\n", - " initialize_model_for_speech_segmentation,\n", - " initialize_model_for_speech_classification,\n", - " initialize_model_for_speech_recognition,\n", - " transcribe\n", - ")\n", - "from asr.lm import SequenceScore\n", - "from asr.comparison import TokenizedText, MultipleTextsAlignment, filter_correction_suggestions\n", - "from asr.whisper_scores import whisper_pipeline_transcribe_with_word_scores" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "dataset = (\n", - " load_dataset('dangrebenkin/long_audio_youtube_lectures')\n", - " .cast_column('audio', Audio(sampling_rate=16_000))\n", - " ['train']\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sample = dataset[2]\n", - "waveform = sample['audio']['array']\n", - "sample['name']" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "segmenter = initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos')\n", - "whisper_pipeline = initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3')\n", - "\n", - "results = transcribe(\n", - " waveform,\n", - " segmenter=segmenter,\n", - " voice_activity_detector=lambda audio: [{'score': 1, 'label': 'Speech'}],\n", - " asr=lambda audio: {'text': 'none'},\n", - " min_segment_size=1,\n", - " max_segment_size=20,\n", - ")\n", - "\n", - "tokenized_segments = []\n", - "scores_per_word = []\n", - "\n", - "for segment in tqdm(results):\n", - " waveform_segment = waveform[int(segment.start * 16_000):int(segment.end * 16_000)]\n", - " tokenized_text_for_segment, _, scores_for_segment = (\n", - " whisper_pipeline_transcribe_with_word_scores(waveform_segment, whisper_pipeline)\n", - " )\n", - " tokenized_segments.append(tokenized_text_for_segment)\n", - " scores_per_word += scores_for_segment\n", - "\n", - "tokenized_text = TokenizedText.concatenate(tokenized_segments)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from transformers.models.whisper.tokenization_whisper import bytes_to_unicode\n", - "\n", - "\n", - "feature_extractor = whisper_pipeline.feature_extractor\n", - "tokenizer = whisper_pipeline.tokenizer\n", - "model = whisper_pipeline.model\n", - "generate_kwargs = whisper_pipeline._forward_params\n", - "\n", - "inputs = feature_extractor(\n", - " waveform_segment,\n", - " return_tensors='pt',\n", - " sampling_rate=16_000,\n", - ").to(model.device, model.dtype)\n", - "result = model.generate(\n", - " **inputs,\n", - " **generate_kwargs,\n", - " return_dict_in_generate=True,\n", - " return_token_timestamps=True,\n", - ")\n", - "\n", - "# convert token ids and logits to numpy\n", - "token_ids = result['sequences'][0].cpu().numpy()\n", - "logits = torch.nn.functional.log_softmax(torch.stack(result['scores']), dim=-1).cpu().numpy()\n", - "\n", - "# skip start special tokens to align with logits\n", - "token_ids = token_ids[-len(logits):]\n", - "\n", - "# skip all special tokens\n", - "is_special = np.array([id in tokenizer.all_special_ids for id in token_ids])\n", - "token_ids = token_ids[~is_special]\n", - "logits = logits[~is_special]\n", - "\n", - "score_per_token = np.array([float(l[0, token_id]) for token_id, l in zip(token_ids, logits)])\n", - "\n", - "# reproducing whisper bpe decoding\n", - "byte_decoder = {v: k for k, v in bytes_to_unicode().items()}\n", - "bytes_list_per_token = [\n", - " [byte_decoder[x] for x in bytes_str]\n", - " for bytes_str in tokenizer.convert_ids_to_tokens(token_ids)\n", - "]\n", - "\n", - "# searching for token positions in the text\n", - "token_end_positions = []\n", - "for i in range(len(bytes_list_per_token)):\n", - " concatenated_bytes = sum(bytes_list_per_token[:i + 1], [])\n", - " try:\n", - " text = bytearray(concatenated_bytes).decode('utf-8', errors='strict')\n", - " token_end_positions.append(len(text))\n", - " except UnicodeDecodeError:\n", - " token_end_positions.append(None) # not a full utf-8 charachter\n", - "\n", - "assert text == tokenizer.decode(token_ids, clean_up_tokenization_spaces=False)\n", - "\n", - "# cleaning up tokenization spaces, shifting token_end_positions\n", - "# (see .clean_up_tokenization() in PreTrainedTokenizerBase)\n", - "if tokenizer.clean_up_tokenization_spaces:\n", - " for replace_from in [\" .\", \" ?\", \" !\", \" ,\", \" ' \", \" n't\", \" 'm\", \" 's\", \" 've\", \" 're\"]:\n", - " replace_to = replace_from.strip()\n", - " while (start_pos := text.find(replace_from)) != -1:\n", - " delta_len = len(replace_to) - len(replace_from)\n", - " text = text[:start_pos] + replace_to + text[start_pos + len(replace_from):]\n", - " token_end_positions = [\n", - " (\n", - " token_end_pos\n", - " if token_end_pos <= start_pos\n", - " else token_end_pos + delta_len\n", - " )\n", - " for token_end_pos in token_end_positions\n", - " ]\n", - "\n", - " assert text == tokenizer.decode(token_ids)\n", - "\n", - "# tokenizing the text\n", - "tokenized_text = TokenizedText.from_text(text)\n", - "\n", - "# matching words and tokens\n", - "tokens_range_per_word = []\n", - "for word in tokenized_text.get_words():\n", - " first_token_idx = None # first token of the word, inclusive\n", - " for token_idx, token_end_pos in enumerate(token_end_positions):\n", - " if token_end_pos is None:\n", - " continue\n", - " if token_end_pos > word.start and first_token_idx is None:\n", - " first_token_idx = token_idx\n", - " if token_end_pos >= word.stop:\n", - " break\n", - " tokens_range_per_word.append((first_token_idx, token_idx + 1))\n", - "\n", - "tokens_per_word = [\n", - " [\n", - " bytearray(b).decode('utf-8', errors='replace')\n", - " for b in bytes_list_per_token[start_token_idx:end_token_idx]\n", - " ]\n", - " for start_token_idx, end_token_idx in tokens_range_per_word\n", - "]\n", - "\n", - "token_scores_per_word = [\n", - " list(score_per_token[start_token_idx:end_token_idx])\n", - " for start_token_idx, end_token_idx in tokens_range_per_word\n", - "]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "bytearray(sum(bytes_list_per_token, [])).decode('utf-8', errors='strict')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "text" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "tokenizer.decode(token_ids, skip_special_tokens=False, clean_up_tokenization_spaces=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "output_dir = Path('/home/oleg/pisets_test_results_with_scores')\n", - "output_dir.mkdir(parents=True, exist_ok=True)\n", - "\n", - "filepath = output_dir / f'{sample[\"name\"]} Pisets WhisperV3 no-VAD (segments 1s-20s) with scores.json'\n", - "\n", - "with open(filepath, 'w') as f:\n", - " json.dump({\n", - " 'tokenized_text': dataclasses.asdict(tokenized_text),\n", - " 'scores_per_word': scores_per_word,\n", - " }, f, ensure_ascii=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "!cat \"/home/oleg/pisets_test_results_with_scores/savvateev Pisets WhisperV3 no-VAD (segments 1s-20s) with scores.json\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def get_all_subsets(elements: list[Any]):\n", - " \"\"\"\n", - " Returns all subsets of a list.\n", - " ```\n", - " get_all_subsets([1, 2, 3])\n", - " >>> [(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]\n", - " ```\n", - " \"\"\"\n", - " return sum((\n", - " [list(x) for x in combinations(elements, r)]\n", - " for r in range(len(elements) + 1)\n", - " ), [])\n", - "\n", - "base = transcriptions['galore']['whisperV3_long_segments_ru']\n", - "additional = transcriptions['galore']['w2v2_golos_lm']\n", - "truth = transcriptions['galore']['truth']\n", - "\n", - "MultipleTextsAlignment.from_strings(truth, base).wer()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "is_uncertain = MultipleTextsAlignment.from_strings(base, additional).get_uncertainty_mask()\n", - "print('Uncertain words ratio', is_uncertain.mean())\n", - "MultipleTextsAlignment.from_strings(truth, base).wer(uncertainty_mask=is_uncertain)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "alignment = MultipleTextsAlignment.from_strings(base, additional)\n", - "orig_indices_to_resolve = filter_correction_suggestions(alignment, skip_word_form_change=False)\n", - "indices_to_resolve = orig_indices_to_resolve.copy()\n", - "indices_accepted = []\n", - "\n", - "# print(alignment.substitute(show_in_braces=indices_to_resolve))\n", - "\n", - "depth = 2\n", - "\n", - "context_before = 100\n", - "context_after = 100\n", - "\n", - "while len(indices_to_resolve):\n", - " print(f'{len(indices_to_resolve)} indices remaining')\n", - "\n", - " indices = indices_to_resolve[:depth]\n", - "\n", - " variants: list[list[int]] = get_all_subsets(indices)\n", - "\n", - " scores = {}\n", - "\n", - " for indices_to_consider in get_all_subsets(indices):\n", - " text = alignment.substitute(replace=indices_accepted + indices_to_consider)\n", - "\n", - " start_idx = alignment.matches[indices[0]].char_start1\n", - " end_idx = alignment.matches[indices[-1]].char_end1 + len(text) - len(alignment.text1.text)\n", - "\n", - " start_idx -= context_before\n", - " end_idx += context_after\n", - "\n", - " start_idx = np.clip(start_idx, 0, len(text))\n", - " end_idx = np.clip(end_idx, 0, len(text))\n", - "\n", - " text = text[start_idx:end_idx]\n", - "\n", - " scores[tuple(indices_to_consider)] = {\n", - " 'score': sequence_score(text),\n", - " 'text' : text\n", - " }\n", - "\n", - " print([x['score'] for x in scores.values()])\n", - "\n", - " best_option = max(scores, key=lambda k: scores[k]['score'])\n", - "\n", - " should_accept_index = indices[0] in best_option\n", - "\n", - " if should_accept_index:\n", - " indices_accepted.append(indices[0])\n", - " \n", - " indices_to_resolve = indices_to_resolve[1:]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "corrected = alignment.substitute(replace=indices_accepted)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "MultipleTextsAlignment.from_strings(truth, corrected).wer()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "is_uncertain = MultipleTextsAlignment.from_strings(base, corrected).get_uncertainty_mask()\n", - "print('Uncertain words ratio', is_uncertain.mean())\n", - "MultipleTextsAlignment.from_strings(truth, base).wer(uncertainty_mask=is_uncertain)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "alignment = MultipleTextsAlignment.from_strings(base, additional)\n", - "\n", - "print(alignment.substitute(\n", - " show_in_braces=[i for i, op in enumerate(alignment.matches) if not op.is_equal]\n", - "))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "alignment = MultipleTextsAlignment.from_strings(truth, base)\n", - "\n", - "print(alignment.substitute(\n", - " show_in_braces=[i for i, op in enumerate(alignment.matches) if not op.is_equal]\n", - "))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "alignment = MultipleTextsAlignment.from_strings(base, corrected)\n", - "\n", - "print(alignment.substitute(\n", - " show_in_braces=filter_correction_suggestions(alignment, skip_word_form_change=False)\n", - "))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# print(alignment.substitute(\n", - "# show_in_braces=orig_indices_to_resolve,\n", - "# pref_second=indices_accepted,\n", - "# pref_first=set(orig_indices_to_resolve) - set(indices_accepted),\n", - "# ))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "'''\n", - "I have two speech recognition models (the first model is usually better) and compare their predictions. In the following text, the disagreement between models is highlighted in braces.\n", - "\n", - "- {aaa|bbb} means that the second model wants to replace \"aaa\" with \"bbb\"\n", - "- {+xx} means that the second model wants to insert \"xx\" into the first model predictions\n", - "- {yy} means that the second model wants to remove \"yy\" from the first model predictions\n", - "\n", - "Based on linguistic knowledge and common sense, please resolve the disagreement and write the final transcription without braces.\n", - "\n", - "The text:\n", - "'''" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/evaluation/calc_metrics.ipynb b/evaluation/calc_metrics.ipynb index 5a81956..32980fb 100644 --- a/evaluation/calc_metrics.ipynb +++ b/evaluation/calc_metrics.ipynb @@ -2,20 +2,58 @@ "cells": [ { "cell_type": "code", - "execution_count": 19, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import json\n", "from pathlib import Path\n", + "from itertools import combinations\n", + "from typing import Any\n", "\n", + "from tqdm import tqdm\n", + "import numpy as np\n", "import pandas as pd\n", "from datasets import load_dataset\n", + "import matplotlib.pyplot as plt\n", "\n", - "from asr.comparison import MultipleTextsAlignment\n", - "\n", + "from asr.comparison import MultipleTextsAlignment, filter_correction_suggestions, TokenizedText, Substring\n", + "from asr.lm import SequenceScore, accept_suggestions_by_lm" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading results from disk" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ "dataset = load_dataset('dangrebenkin/long_audio_youtube_lectures')['train']\n", - "name_to_transcription = dict(zip(dataset['name'], dataset['transcription']))\n", + "name_to_transcription = dict(zip(dataset['name'], dataset['transcription']))" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 77/77 [00:25<00:00, 3.04it/s]\n", + "100%|██████████| 7/7 [00:02<00:00, 3.01it/s]\n" + ] + } + ], + "source": [ + "# reading results\n", "\n", "results_list = []\n", "for filepath in Path('/home/oleg/pisets_test_results').glob('*.json'):\n", @@ -29,19 +67,239 @@ "\n", "results = pd.DataFrame(results_list)\n", "\n", - "results['alignment'] = results.apply(\n", + "tqdm.pandas()\n", + "results['alignment'] = results.progress_apply(\n", " lambda row: MultipleTextsAlignment.from_strings(\n", " name_to_transcription[row['audio_name']],\n", " row['transcription']\n", " ),\n", " axis='columns'\n", ")\n", - "results['wer'] = results['alignment'].apply(lambda al: al.wer()['wer'])" + "del results['transcription']\n", + "results['wer'] = results['alignment'].apply(lambda al: al.wer()['wer'])\n", + "\n", + "# reading results with token-wise Whisper scores\n", + "\n", + "results_with_scores = []\n", + "\n", + "for filepath in Path('/home/oleg/pisets_test_results_with_scores').glob('*.json'):\n", + " with open(filepath) as f:\n", + " data = json.load(f)\n", + "\n", + " # reconstructing dataclasses from dicts\n", + " data['tokenized_text']['tokens'] = [\n", + " Substring(**x) for x in data['tokenized_text']['tokens']\n", + " ]\n", + " data['tokenized_text'] = TokenizedText(**data['tokenized_text'])\n", + "\n", + " data['pipeline_name'] = data.pop('transcriber_name')\n", + " results_with_scores.append(data)\n", + "\n", + "results_with_scores = pd.DataFrame(results_with_scores)\n", + "results_with_scores['alignment'] = results_with_scores.progress_apply(\n", + " lambda row: MultipleTextsAlignment.from_strings(\n", + " name_to_transcription[row['audio_name']],\n", + " row['tokenized_text']\n", + " ),\n", + " axis='columns'\n", + ")\n", + "del results_with_scores['tokenized_text']\n", + "results_with_scores['wer'] = results_with_scores['alignment'].apply(lambda al: al.wer()['wer'])\n", + "\n", + "# concatenating\n", + "\n", + "results = pd.concat([results, results_with_scores], axis='rows')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
audio_namepipeline_namealignmentwerscores_per_word
0kolodezevBaseline Whisper longformMultipleTextsAlignment(text1=TokenizedText(tex...0.161696NaN
1zhirinovskyPisets WhisperV3 no-VAD (segments 1s-20s)MultipleTextsAlignment(text1=TokenizedText(tex...0.052458NaN
2zhirinovskyPisets WhisperV3 no-VAD stretched (segments 1s...MultipleTextsAlignment(text1=TokenizedText(tex...0.064849NaN
3lankovPisets WhisperV3 no-VAD Podlodka (segments 1s-...MultipleTextsAlignment(text1=TokenizedText(tex...0.097544NaN
4kolodezevBaseline Whisper longform conditionedMultipleTextsAlignment(text1=TokenizedText(tex...0.276680NaN
..................
2lankovPisets WhisperV3 no-VAD (segments 1s-20s) with...MultipleTextsAlignment(text1=TokenizedText(tex...0.089934[[-0.4045039713382721], [-0.25986623764038086]...
3zaliznyakPisets WhisperV3 no-VAD (segments 1s-20s) with...MultipleTextsAlignment(text1=TokenizedText(tex...0.112038[[-0.1870197057723999, -6.603976362384856e-05]...
4savvateevPisets WhisperV3 no-VAD (segments 1s-20s) with...MultipleTextsAlignment(text1=TokenizedText(tex...0.162985[[-0.6434793472290039], [-0.008379065431654453...
5kolodezevPisets WhisperV3 no-VAD (segments 1s-20s) with...MultipleTextsAlignment(text1=TokenizedText(tex...0.127201[[-1.3415793180465698, -0.010715918615460396],...
6zhirinovskyPisets WhisperV3 no-VAD (segments 1s-20s) with...MultipleTextsAlignment(text1=TokenizedText(tex...0.053697[[-1.8754836320877075, -0.01690865121781826], ...
\n", + "

84 rows × 5 columns

\n", + "
" + ], + "text/plain": [ + " audio_name pipeline_name \\\n", + "0 kolodezev Baseline Whisper longform \n", + "1 zhirinovsky Pisets WhisperV3 no-VAD (segments 1s-20s) \n", + "2 zhirinovsky Pisets WhisperV3 no-VAD stretched (segments 1s... \n", + "3 lankov Pisets WhisperV3 no-VAD Podlodka (segments 1s-... \n", + "4 kolodezev Baseline Whisper longform conditioned \n", + ".. ... ... \n", + "2 lankov Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "3 zaliznyak Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "4 savvateev Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "5 kolodezev Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "6 zhirinovsky Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "\n", + " alignment wer \\\n", + "0 MultipleTextsAlignment(text1=TokenizedText(tex... 0.161696 \n", + "1 MultipleTextsAlignment(text1=TokenizedText(tex... 0.052458 \n", + "2 MultipleTextsAlignment(text1=TokenizedText(tex... 0.064849 \n", + "3 MultipleTextsAlignment(text1=TokenizedText(tex... 0.097544 \n", + "4 MultipleTextsAlignment(text1=TokenizedText(tex... 0.276680 \n", + ".. ... ... \n", + "2 MultipleTextsAlignment(text1=TokenizedText(tex... 0.089934 \n", + "3 MultipleTextsAlignment(text1=TokenizedText(tex... 0.112038 \n", + "4 MultipleTextsAlignment(text1=TokenizedText(tex... 0.162985 \n", + "5 MultipleTextsAlignment(text1=TokenizedText(tex... 0.127201 \n", + "6 MultipleTextsAlignment(text1=TokenizedText(tex... 0.053697 \n", + "\n", + " scores_per_word \n", + "0 NaN \n", + "1 NaN \n", + "2 NaN \n", + "3 NaN \n", + "4 NaN \n", + ".. ... \n", + "2 [[-0.4045039713382721], [-0.25986623764038086]... \n", + "3 [[-0.1870197057723999, -6.603976362384856e-05]... \n", + "4 [[-0.6434793472290039], [-0.008379065431654453... \n", + "5 [[-1.3415793180465698, -0.010715918615460396],... \n", + "6 [[-1.8754836320877075, -0.01690865121781826], ... \n", + "\n", + "[84 rows x 5 columns]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "results" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## WER results" ] }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -88,12 +346,22 @@ " \n", " Baseline Whisper longform\n", " 0.010929\n", - " NaN\n", - " NaN\n", - " NaN\n", - " NaN\n", + " 0.161696\n", + " 0.103079\n", + " 0.206186\n", + " 0.169576\n", " 0.158086\n", - " NaN\n", + " 0.043371\n", + " \n", + " \n", + " Baseline Whisper longform conditioned\n", + " 0.050546\n", + " 0.276680\n", + " 0.123833\n", + " 0.230241\n", + " 0.139963\n", + " 0.678753\n", + " 0.064436\n", " \n", " \n", " Baseline Whisper pipeline\n", @@ -106,22 +374,82 @@ " 0.115655\n", " \n", " \n", + " Pisets WhisperV3 (segments 10s-30s)\n", + " 0.011840\n", + " 0.134028\n", + " 0.131788\n", + " 0.182622\n", + " 0.159913\n", + " 0.113125\n", + " 0.067741\n", + " \n", + " \n", " Pisets WhisperV3 (segments 1s-20s)\n", " 0.015938\n", " 0.129357\n", " 0.087513\n", " 0.216986\n", - " NaN\n", + " 0.131234\n", " 0.116751\n", " 0.060306\n", " \n", " \n", + " Pisets WhisperV3 Podlodka (segments 1s-20s)\n", + " 0.030965\n", + " 0.102767\n", + " 0.097544\n", + " 0.291114\n", + " 0.076372\n", + " 0.116389\n", + " 0.088806\n", + " \n", + " \n", + " Pisets WhisperV3 no-VAD (segments 1s-20s)\n", + " 0.015938\n", + " 0.129357\n", + " 0.087513\n", + " 0.186058\n", + " 0.131234\n", + " 0.106599\n", + " 0.052458\n", + " \n", + " \n", + " Pisets WhisperV3 no-VAD (segments 1s-20s) with scores\n", + " 0.016849\n", + " 0.127201\n", + " 0.089934\n", + " 0.162985\n", + " 0.129676\n", + " 0.112038\n", + " 0.053697\n", + " \n", + " \n", + " Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s)\n", + " 0.030965\n", + " 0.102767\n", + " 0.097544\n", + " 0.259205\n", + " 0.076372\n", + " 0.106962\n", + " 0.081371\n", + " \n", + " \n", + " Pisets WhisperV3 no-VAD stretched (segments 1s-20s)\n", + " 0.037796\n", + " 0.114984\n", + " 0.109997\n", + " 0.316642\n", + " 0.118454\n", + " 0.129442\n", + " 0.064849\n", + " \n", + " \n", " Pisets WhisperV3 stretched (segments 1s-20s)\n", " 0.037796\n", " 0.114984\n", " 0.109997\n", " 0.348061\n", - " NaN\n", + " 0.118454\n", " 0.139231\n", " 0.072697\n", " \n", @@ -131,7 +459,7 @@ " 0.271649\n", " 0.316845\n", " 0.629357\n", - " NaN\n", + " 0.279302\n", " 0.250544\n", " 0.261875\n", " \n", @@ -140,32 +468,68 @@ "" ], "text/plain": [ - "audio_name harvard kolodezev lankov \\\n", - "pipeline_name \n", - "Baseline Whisper longform 0.010929 NaN NaN \n", - "Baseline Whisper pipeline 0.045537 0.155228 0.147354 \n", - "Pisets WhisperV3 (segments 1s-20s) 0.015938 0.129357 0.087513 \n", - "Pisets WhisperV3 stretched (segments 1s-20s) 0.037796 0.114984 0.109997 \n", - "W2V2 Golos LM 0.149818 0.271649 0.316845 \n", + "audio_name harvard kolodezev \\\n", + "pipeline_name \n", + "Baseline Whisper longform 0.010929 0.161696 \n", + "Baseline Whisper longform conditioned 0.050546 0.276680 \n", + "Baseline Whisper pipeline 0.045537 0.155228 \n", + "Pisets WhisperV3 (segments 10s-30s) 0.011840 0.134028 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.015938 0.129357 \n", + "Pisets WhisperV3 Podlodka (segments 1s-20s) 0.030965 0.102767 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) 0.015938 0.129357 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) with ... 0.016849 0.127201 \n", + "Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s) 0.030965 0.102767 \n", + "Pisets WhisperV3 no-VAD stretched (segments 1s-... 0.037796 0.114984 \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.037796 0.114984 \n", + "W2V2 Golos LM 0.149818 0.271649 \n", + "\n", + "audio_name lankov savvateev \\\n", + "pipeline_name \n", + "Baseline Whisper longform 0.103079 0.206186 \n", + "Baseline Whisper longform conditioned 0.123833 0.230241 \n", + "Baseline Whisper pipeline 0.147354 0.192440 \n", + "Pisets WhisperV3 (segments 10s-30s) 0.131788 0.182622 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.087513 0.216986 \n", + "Pisets WhisperV3 Podlodka (segments 1s-20s) 0.097544 0.291114 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) 0.087513 0.186058 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) with ... 0.089934 0.162985 \n", + "Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s) 0.097544 0.259205 \n", + "Pisets WhisperV3 no-VAD stretched (segments 1s-... 0.109997 0.316642 \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.109997 0.348061 \n", + "W2V2 Golos LM 0.316845 0.629357 \n", "\n", - "audio_name savvateev tuberculosis \\\n", - "pipeline_name \n", - "Baseline Whisper longform NaN NaN \n", - "Baseline Whisper pipeline 0.192440 0.199501 \n", - "Pisets WhisperV3 (segments 1s-20s) 0.216986 NaN \n", - "Pisets WhisperV3 stretched (segments 1s-20s) 0.348061 NaN \n", - "W2V2 Golos LM 0.629357 NaN \n", + "audio_name tuberculosis zaliznyak \\\n", + "pipeline_name \n", + "Baseline Whisper longform 0.169576 0.158086 \n", + "Baseline Whisper longform conditioned 0.139963 0.678753 \n", + "Baseline Whisper pipeline 0.199501 0.131617 \n", + "Pisets WhisperV3 (segments 10s-30s) 0.159913 0.113125 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.131234 0.116751 \n", + "Pisets WhisperV3 Podlodka (segments 1s-20s) 0.076372 0.116389 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) 0.131234 0.106599 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) with ... 0.129676 0.112038 \n", + "Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s) 0.076372 0.106962 \n", + "Pisets WhisperV3 no-VAD stretched (segments 1s-... 0.118454 0.129442 \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.118454 0.139231 \n", + "W2V2 Golos LM 0.279302 0.250544 \n", "\n", - "audio_name zaliznyak zhirinovsky \n", - "pipeline_name \n", - "Baseline Whisper longform 0.158086 NaN \n", - "Baseline Whisper pipeline 0.131617 0.115655 \n", - "Pisets WhisperV3 (segments 1s-20s) 0.116751 0.060306 \n", - "Pisets WhisperV3 stretched (segments 1s-20s) 0.139231 0.072697 \n", - "W2V2 Golos LM 0.250544 0.261875 " + "audio_name zhirinovsky \n", + "pipeline_name \n", + "Baseline Whisper longform 0.043371 \n", + "Baseline Whisper longform conditioned 0.064436 \n", + "Baseline Whisper pipeline 0.115655 \n", + "Pisets WhisperV3 (segments 10s-30s) 0.067741 \n", + "Pisets WhisperV3 (segments 1s-20s) 0.060306 \n", + "Pisets WhisperV3 Podlodka (segments 1s-20s) 0.088806 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) 0.052458 \n", + "Pisets WhisperV3 no-VAD (segments 1s-20s) with ... 0.053697 \n", + "Pisets WhisperV3 no-VAD Podlodka (segments 1s-20s) 0.081371 \n", + "Pisets WhisperV3 no-VAD stretched (segments 1s-... 0.064849 \n", + "Pisets WhisperV3 stretched (segments 1s-20s) 0.072697 \n", + "W2V2 Golos LM 0.261875 " ] }, - "execution_count": 20, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -174,6 +538,216 @@ "results.pivot_table(values='wer', index='pipeline_name', columns='audio_name')" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uncertainty with model disagreement\n", + "\n", + "\"Method 3: LM filtering\" may take a lot of time. It will be saved on disk as soon as it is calculated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "uncertainty_results = []\n", + "\n", + "scorer = SequenceScore('ai-forever/rugpt3large_based_on_gpt2')\n", + "\n", + "for audio_name in name_to_transcription:\n", + "\n", + " base_pipeline_name = 'Pisets WhisperV3 no-VAD (segments 1s-20s) with scores'\n", + "\n", + " truth_vs_base: MultipleTextsAlignment = results.query(\n", + " f'audio_name == \"{audio_name}\" and pipeline_name == \"{base_pipeline_name}\"'\n", + " ).iloc[0]['alignment']\n", + "\n", + " for additional_pipeline_name in [\n", + " 'W2V2 Golos LM',\n", + " 'Pisets WhisperV3 no-VAD stretched (segments 1s-20s)',\n", + " ]:\n", + " additional_predictions: TokenizedText = results.query(\n", + " f'audio_name == \"{audio_name}\" and pipeline_name == \"{additional_pipeline_name}\"'\n", + " ).iloc[0]['alignment'].text2\n", + "\n", + " base_vs_additional = MultipleTextsAlignment.from_strings(truth_vs_base.text2, additional_predictions)\n", + "\n", + " # method 1: no filtering\n", + " print(base_pipeline_name, additional_pipeline_name, 'all diffs')\n", + "\n", + " is_uncertain = base_vs_additional.get_uncertainty_mask()\n", + " uncertainty_results.append({\n", + " 'audio_name': audio_name,\n", + " 'base_pipeline': base_pipeline_name,\n", + " 'additional_pipeline': additional_pipeline_name,\n", + " 'method': 'all diffs',\n", + " 'mask': is_uncertain,\n", + " 'metrics': truth_vs_base.wer(uncertainty_mask=is_uncertain)\n", + " })\n", + "\n", + " # method 2: filtering\n", + " print(base_pipeline_name, additional_pipeline_name, 'filtered diffs')\n", + "\n", + " correction_indices = filter_correction_suggestions(base_vs_additional, skip_word_form_change=False, pbar=False)\n", + " is_uncertain = base_vs_additional.get_uncertainty_mask(match_indices=correction_indices)\n", + " uncertainty_results.append({\n", + " 'audio_name': audio_name,\n", + " 'base_pipeline': base_pipeline_name,\n", + " 'additional_pipeline': additional_pipeline_name,\n", + " 'method': 'filtered diffs',\n", + " 'mask': is_uncertain,\n", + " 'metrics': truth_vs_base.wer(uncertainty_mask=is_uncertain)\n", + " })\n", + "\n", + " # method 3: LM filtering\n", + " print(base_pipeline_name, additional_pipeline_name, 'LM filtered diffs')\n", + "\n", + " cache_path = (\n", + " Path('/home/oleg/pisets_test_results_lm')\n", + " / f'[{audio_name}] [{base_pipeline_name}] [{additional_pipeline_name}].json'\n", + " )\n", + " if cache_path.is_file():\n", + " lm_filtered_suggestion_indices = json.loads(cache_path.read_text())['indices']\n", + " else:\n", + " lm_filtered_suggestion_indices = accept_suggestions_by_lm(\n", + " base_vs_additional,\n", + " [i for i, m in enumerate(base_vs_additional.matches) if not m.is_equal],\n", + " scorer,\n", + " pbar=False,\n", + " verbose=False,\n", + " )\n", + " cache_path.parent.mkdir(parents=True, exist_ok=True)\n", + " cache_path.write_text(json.dumps({'indices': lm_filtered_suggestion_indices}))\n", + " is_uncertain = base_vs_additional.get_uncertainty_mask(match_indices=lm_filtered_suggestion_indices)\n", + " uncertainty_results.append({\n", + " 'audio_name': audio_name,\n", + " 'base_pipeline': base_pipeline_name,\n", + " 'additional_pipeline': additional_pipeline_name,\n", + " 'method': 'LM filtered diffs',\n", + " 'mask': is_uncertain,\n", + " 'metrics': truth_vs_base.wer(uncertainty_mask=is_uncertain)\n", + " })" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uncertainty with Whisper sequence score" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for audio_name in name_to_transcription:\n", + "\n", + " base_pipeline_name = 'Pisets WhisperV3 no-VAD (segments 1s-20s) with scores'\n", + "\n", + " row = results.query(\n", + " f'audio_name == \"{audio_name}\" and pipeline_name == \"{base_pipeline_name}\"'\n", + " ).iloc[0]\n", + " truth_vs_base = row['alignment']\n", + " scores_per_word = row['scores_per_word']\n", + "\n", + " reductions = {'min': min, 'mean': np.mean}\n", + " log_proba_thresholds = np.linspace(-1.5, -0.1, num=15)\n", + "\n", + " for reduction_name, reduction_fn in reductions.items():\n", + " for log_proba_threshold in log_proba_thresholds:\n", + " is_uncertain = np.array([reduction_fn(s) for s in scores_per_word]) < log_proba_threshold\n", + " uncertainty_results.append({\n", + " 'audio_name': audio_name,\n", + " 'base_pipeline': base_pipeline_name,\n", + " 'method': f'WhisperLogProba_{reduction_name}',\n", + " 't': log_proba_threshold,\n", + " 'mask': is_uncertain,\n", + " 'metrics': truth_vs_base.wer(uncertainty_mask=is_uncertain),\n", + " })" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "uncertainty_results = pd.DataFrame(uncertainty_results)\n", + "uncertainty_results" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uncertainty plots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plt.figure(figsize=(12, 6))\n", + "\n", + "show_for_all_datasets = True\n", + "\n", + "x_statistics = 'uncertainty_ratio'\n", + "y_statistics = 'recall'\n", + "\n", + "for i, ((base_pipeline, additional_pipeline, method), group_loc) in enumerate(\n", + " uncertainty_results.groupby(['base_pipeline', 'additional_pipeline', 'method']).groups.items()\n", + "):\n", + " group = uncertainty_results.loc[group_loc]\n", + " color = f'C{i}'\n", + " has_t = not pd.isna(group['t'].values[0])\n", + " \n", + " if not pd.isna(additional_pipeline):\n", + " label = f'{base_pipeline.replace(\" with scores\", \"\")} | {additional_pipeline} | {method}'\n", + " else:\n", + " label = f'{base_pipeline.replace(\" with scores\", \"\")} | {method}'\n", + "\n", + " if not has_t:\n", + " # no parameter, scatter plot\n", + " assert group.audio_name.nunique() == len(group)\n", + " xs = [m[x_statistics] for m in group.metrics]\n", + " ys = [m[y_statistics] for m in group.metrics]\n", + " if show_for_all_datasets:\n", + " plt.scatter(xs, ys, alpha=0.1, color=color)\n", + " plt.scatter([np.mean(xs)], [np.mean(ys)], label=label, color=color)\n", + " \n", + " else:\n", + " # has a parameter, line plot\n", + " t_range = sorted(group['t'].unique())\n", + "\n", + " xs = []\n", + " ys = []\n", + " for t in t_range:\n", + " group_for_t = group[group['t'] == t]\n", + " assert group_for_t.audio_name.nunique() == len(group_for_t)\n", + " xs.append([m[x_statistics] for m in group_for_t.metrics])\n", + " ys.append([m[y_statistics] for m in group_for_t.metrics])\n", + "\n", + " xs = np.array(xs).T # shape: (n_audios, n_t_values)\n", + " ys = np.array(ys).T # shape: (n_audios, n_t_values)\n", + "\n", + " if show_for_all_datasets:\n", + " for _xs, _ys in zip(xs, ys):\n", + " plt.plot(_xs, _ys, alpha=0.1, color=color)\n", + " plt.plot(xs.mean(axis=0), ys.mean(axis=0), label=label, color=color)\n", + "\n", + "plt.xlabel(x_statistics)\n", + "plt.ylabel(y_statistics)\n", + "plt.legend()\n", + "plt.show()" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/evaluation/make_predictions.py b/evaluation/make_predictions.py index 28d17c8..6106615 100644 --- a/evaluation/make_predictions.py +++ b/evaluation/make_predictions.py @@ -179,12 +179,12 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: max_segment_size=30, asr_predictions_name='Pisets WhisperV3 (segments 10s-30s)', ), - 'W2V2 golos no LM': lambda: TranscribePisets( - segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos'), - vad='skip', - asr='skip', - segmenter_predictions_name='W2V2 Golos no LM', - ), + # 'W2V2 golos no LM': lambda: TranscribePisets( + # segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos'), + # vad='skip', + # asr='skip', + # segmenter_predictions_name='W2V2 Golos no LM', + # ), 'Pisets Podlodka': lambda: TranscribePisets( segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), vad=initialize_model_for_speech_classification(), @@ -199,7 +199,9 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: asr=initialize_model_for_speech_recognition('ru', 'openai/whisper-large-v3'), min_segment_size=1, max_segment_size=20, + stretch=(3, 4), asr_predictions_name='Pisets WhisperV3 no-VAD (segments 1s-20s)', + asr_stretched_predictions_name='Pisets WhisperV3 no-VAD stretched (segments 1s-20s)', ), 'Pisets no-VAD Podlodka': lambda: TranscribePisets( segmenter=initialize_model_for_speech_segmentation('ru', 'bond005/wav2vec2-large-ru-golos-with-lm'), diff --git a/evaluation/make_predictions_with_whisper_scores.py b/evaluation/make_predictions_with_whisper_scores.py index 1c6aa78..31f6032 100644 --- a/evaluation/make_predictions_with_whisper_scores.py +++ b/evaluation/make_predictions_with_whisper_scores.py @@ -53,10 +53,14 @@ tokenized_text = TokenizedText.concatenate(tokenized_segments) - filepath = output_dir / f'{sample["name"]} Pisets WhisperV3 no-VAD (segments 1s-20s) with scores.json' + transcriber_name = 'Pisets WhisperV3 no-VAD (segments 1s-20s) with scores' + + filepath = output_dir / f'{sample["name"]} {transcriber_name}.json' with open(filepath, 'w') as f: json.dump({ + 'audio_name': sample['name'], + 'transcriber_name': transcriber_name, 'tokenized_text': dataclasses.asdict(tokenized_text), 'scores_per_word': scores_per_word, }, f, ensure_ascii=False) \ No newline at end of file diff --git a/evaluation/requirements.txt b/evaluation/requirements.txt index eef8b5c..b4d3ca3 100644 --- a/evaluation/requirements.txt +++ b/evaluation/requirements.txt @@ -1,3 +1,4 @@ pysrt soundfile>=0.12.1 -librosa \ No newline at end of file +librosa +matplotlib \ No newline at end of file From 2efa6eaca893d32011ac630a61f051e93d9d5a5b Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sun, 1 Dec 2024 20:46:51 +0300 Subject: [PATCH 22/24] uncertainty metrics plots --- evaluation/calc_metrics.ipynb | 421 ++++++++++++++++++++++++++++++++-- 1 file changed, 405 insertions(+), 16 deletions(-) diff --git a/evaluation/calc_metrics.ipynb b/evaluation/calc_metrics.ipynb index 32980fb..b564d1d 100644 --- a/evaluation/calc_metrics.ipynb +++ b/evaluation/calc_metrics.ipynb @@ -40,15 +40,22 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "100%|██████████| 77/77 [00:25<00:00, 3.04it/s]\n", - "100%|██████████| 7/7 [00:02<00:00, 3.01it/s]\n" + " 0%| | 0/77 [00:00\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
audio_namebase_pipelineadditional_pipelinemethodmaskmetricst
0zaliznyakPisets WhisperV3 no-VAD (segments 1s-20s) with...W2V2 Golos LMall diffs[False, False, False, False, False, False, Fal...{'wer': 0.112037708484409, 'certain_n_correct'...NaN
1zaliznyakPisets WhisperV3 no-VAD (segments 1s-20s) with...W2V2 Golos LMfiltered diffs[False, False, False, False, False, False, Fal...{'wer': 0.112037708484409, 'certain_n_correct'...NaN
2zaliznyakPisets WhisperV3 no-VAD (segments 1s-20s) with...W2V2 Golos LMLM filtered diffs[False, False, False, False, False, False, Fal...{'wer': 0.112037708484409, 'certain_n_correct'...NaN
3zaliznyakPisets WhisperV3 no-VAD (segments 1s-20s) with...Pisets WhisperV3 no-VAD stretched (segments 1s...all diffs[False, False, False, False, False, False, Fal...{'wer': 0.112037708484409, 'certain_n_correct'...NaN
4zaliznyakPisets WhisperV3 no-VAD (segments 1s-20s) with...Pisets WhisperV3 no-VAD stretched (segments 1s...filtered diffs[False, False, False, False, False, False, Fal...{'wer': 0.112037708484409, 'certain_n_correct'...NaN
........................
352tuberculosisPisets WhisperV3 no-VAD (segments 1s-20s) with...NaNWhisperLogProba_sum[False, False, False, True, False, False, Fals...{'wer': 0.12967581047381546, 'certain_n_correc...-0.5
353tuberculosisPisets WhisperV3 no-VAD (segments 1s-20s) with...NaNWhisperLogProba_sum[False, False, False, True, False, False, Fals...{'wer': 0.12967581047381546, 'certain_n_correc...-0.4
354tuberculosisPisets WhisperV3 no-VAD (segments 1s-20s) with...NaNWhisperLogProba_sum[True, False, False, True, False, False, False...{'wer': 0.12967581047381546, 'certain_n_correc...-0.3
355tuberculosisPisets WhisperV3 no-VAD (segments 1s-20s) with...NaNWhisperLogProba_sum[True, False, False, True, False, False, False...{'wer': 0.12967581047381546, 'certain_n_correc...-0.2
356tuberculosisPisets WhisperV3 no-VAD (segments 1s-20s) with...NaNWhisperLogProba_sum[True, False, False, True, False, False, False...{'wer': 0.12967581047381546, 'certain_n_correc...-0.1
\n", + "

357 rows × 7 columns

\n", + "" + ], + "text/plain": [ + " audio_name base_pipeline \\\n", + "0 zaliznyak Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "1 zaliznyak Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "2 zaliznyak Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "3 zaliznyak Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "4 zaliznyak Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + ".. ... ... \n", + "352 tuberculosis Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "353 tuberculosis Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "354 tuberculosis Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "355 tuberculosis Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "356 tuberculosis Pisets WhisperV3 no-VAD (segments 1s-20s) with... \n", + "\n", + " additional_pipeline method \\\n", + "0 W2V2 Golos LM all diffs \n", + "1 W2V2 Golos LM filtered diffs \n", + "2 W2V2 Golos LM LM filtered diffs \n", + "3 Pisets WhisperV3 no-VAD stretched (segments 1s... all diffs \n", + "4 Pisets WhisperV3 no-VAD stretched (segments 1s... filtered diffs \n", + ".. ... ... \n", + "352 NaN WhisperLogProba_sum \n", + "353 NaN WhisperLogProba_sum \n", + "354 NaN WhisperLogProba_sum \n", + "355 NaN WhisperLogProba_sum \n", + "356 NaN WhisperLogProba_sum \n", + "\n", + " mask \\\n", + "0 [False, False, False, False, False, False, Fal... \n", + "1 [False, False, False, False, False, False, Fal... \n", + "2 [False, False, False, False, False, False, Fal... \n", + "3 [False, False, False, False, False, False, Fal... \n", + "4 [False, False, False, False, False, False, Fal... \n", + ".. ... \n", + "352 [False, False, False, True, False, False, Fals... \n", + "353 [False, False, False, True, False, False, Fals... \n", + "354 [True, False, False, True, False, False, False... \n", + "355 [True, False, False, True, False, False, False... \n", + "356 [True, False, False, True, False, False, False... \n", + "\n", + " metrics t \n", + "0 {'wer': 0.112037708484409, 'certain_n_correct'... NaN \n", + "1 {'wer': 0.112037708484409, 'certain_n_correct'... NaN \n", + "2 {'wer': 0.112037708484409, 'certain_n_correct'... NaN \n", + "3 {'wer': 0.112037708484409, 'certain_n_correct'... NaN \n", + "4 {'wer': 0.112037708484409, 'certain_n_correct'... NaN \n", + ".. ... ... \n", + "352 {'wer': 0.12967581047381546, 'certain_n_correc... -0.5 \n", + "353 {'wer': 0.12967581047381546, 'certain_n_correc... -0.4 \n", + "354 {'wer': 0.12967581047381546, 'certain_n_correc... -0.3 \n", + "355 {'wer': 0.12967581047381546, 'certain_n_correc... -0.2 \n", + "356 {'wer': 0.12967581047381546, 'certain_n_correc... -0.1 \n", + "\n", + "[357 rows x 7 columns]" + ] + }, + "execution_count": 99, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "uncertainty_results = pd.DataFrame(uncertainty_results)\n", "uncertainty_results" @@ -685,16 +959,82 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Uncertainty plots" + "## Ensembling uncertainty estimation methods" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 100, "metadata": {}, "outputs": [], "source": [ - "plt.figure(figsize=(12, 6))\n", + "ensemble_uncertainty_results = []\n", + "\n", + "for audio_name in name_to_transcription:\n", + "\n", + " base_pipeline_name = 'Pisets WhisperV3 no-VAD (segments 1s-20s) with scores'\n", + " additional_pipeline_name = 'W2V2 Golos LM'\n", + "\n", + " truth_vs_base = results.query(\n", + " f'audio_name == \"{audio_name}\" and pipeline_name == \"{base_pipeline_name}\"'\n", + " ).iloc[0]['alignment']\n", + "\n", + " t = -1\n", + " row1 = uncertainty_results.query(\n", + " f'audio_name == \"{audio_name}\"'\n", + " f' and base_pipeline == \"{base_pipeline_name}\"'\n", + " ' and method == \"WhisperLogProba_sum\"'\n", + " f' and t > {t - 0.001}'\n", + " f' and t < {t + 0.001}'\n", + " ).iloc[0]\n", + "\n", + " row2 = uncertainty_results.query(\n", + " f'audio_name == \"{audio_name}\"'\n", + " f' and base_pipeline == \"{base_pipeline_name}\"'\n", + " f' and additional_pipeline == \"{additional_pipeline_name}\"'\n", + " ' and method == \"LM filtered diffs\"'\n", + " ).iloc[0]\n", + "\n", + " is_uncertain = row1['mask'] | row2['mask']\n", + "\n", + " ensemble_uncertainty_results.append({\n", + " 'audio_name': audio_name,\n", + " 'base_pipeline': base_pipeline_name,\n", + " 'additional_pipeline': additional_pipeline_name,\n", + " 'method': f'LM filtered diffs + WhisperLogProba_sum (t={t})',\n", + " 'mask': is_uncertain,\n", + " 'metrics': truth_vs_base.wer(uncertainty_mask=is_uncertain),\n", + " })\n", + "\n", + "ensemble_uncertainty_results = pd.DataFrame(ensemble_uncertainty_results)\n", + "uncertainty_results = pd.concat([uncertainty_results, ensemble_uncertainty_results], axis='rows', ignore_index=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Uncertainty plots" + ] + }, + { + "cell_type": "code", + "execution_count": 101, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABNEAAAJaCAYAAAABC9FfAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMywgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/GU6VOAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9eZxleV3n+b++3+85564RkXtWVlZCFasle7NUg7SigghSUEgrLg1Kt9PTbWv7k55pwYVu2hZs6eFHt8s4tlvTyozogOyFCtLu4gKiSFFQFLVlVeUay93O8v1+549z740lIzIjsyIyIyPfz8cjHvWouEucuHHvzTjv+CwmxhgRERERERERERGRDdkrfQAiIiIiIiIiIiI7nUI0ERERERERERGRC1CIJiIiIiIiIiIicgEK0URERERERERERC5AIZqIiIiIiIiIiMgFKEQTERERERERERG5AIVoIiIiIiIiIiIiF6AQTURERERERERE5AKSK30Al1sIgePHjzMzM4Mx5kofjoiIiIiIiIiIXEExRpaWlrj++uuxduN6s2suRDt+/DjHjh270ochIiIiIiIiIiI7yH333ccNN9yw4eXXXIg2MzMD1A/M7OzsFT4aERERERERERG5khYXFzl27Ng0M9rINReiTVo4Z2dnFaKJiIiIiIiIiAjABcd+abGAiIiIiIiIiIjIBShEExERERERERERuQCFaCIiIiIiIiIiIhegEE1EREREREREROQCFKKJiIiIiIiIiIhcgEI0ERERERERERGRC1CIJiIiIiIiIiIicgEK0URERERERERERC5AIZqIiIiIiIiIiMgFKEQTERERERERERG5AIVoIiIiIiIiIiIiF6AQTURERERERERE5AIUoomIiIiIiIiIiFyAQjQREREREREREZELUIgmIiIiIiIiIiJyAQrRRERERERERERELiC50gcgIiIiIiIiIiJXh6osiTFgjCVJ0yt9OJeVQjQRERERERERETmvqigY9pYohkMIAawla7VodWdIsuxKH95loRBNREREREREREQ2VBUFS2dO4cuStNHEOkfwnrzfoypyZvYduCaCNM1EExERERERERGRDQ17S/iypNnp4pIEYwwuSWh2uviyZNhbutKHeFkoRBMRERERERERkXVVZUkxHJI2mutenjaaFMMhVVle5iO7/BSiiYiIiIiIiIjIumIMEALWuXUvt85BCPX1djmFaCIiIiIiIiIisi5jLFhL8H7dy4P3YG19vV1u93+HIiIiIiIiIiJySZI0JWu1KPPRupeX+Yis1SJJ08t8ZJefQjQREREREREREdlQqzuDS1NG/R6+qogx4quKUb+HS1Na3ZkrfYiXRXKlD0BERERERERERLZXrALECMZgkourqUqyjJl9Bxj2liiGQ8hzsJZGp0urO0OSZdt01DuLQjQRERERERERkV0qlgE/KIm5Xw7RGg7XTjHp5sO0OkjbT1WWxBgwxl4TLZwrKUQTEREREREREdmFYhmoFnLwAZPVCwIIkTiqqMpAMte4qCANuOaCs5U0E01EREREREREZBfygxJ8wLYSjLMYYzDOYlsJ+LpCTTZPIZqIiIiIiIiIyC4Tq0DMfV2Btg6TWWLu61lpsikK0UREREREREREdpsY6w9r1r/cmuXryKYoRBMRERERERER2W2MqT/CBiFZiMvXkU1RiCYiIiIiIiIissuYxGIajlis364Zi4BpOEyiaGiz9EiJiIiIiIiIiOxCrp2Cs4RhRfSBGCPRB8KwAmfry2XTkit9ACIiIiIiIiIisvVMaknmGvhBScw9xADGYJoJrp1iUtVWXQyFaCIiIiIiIiIiu9QkSItVqJcIGKMWzkukEE1EREREREREZJdTcPbI6REUERERERERERG5AIVoIiIiIiIiIiIiF6AQTURERERERERE5AIUoomIiIiIiIiIiFyAQjQREREREREREZEL0HZOEZEdrCpLYgwYY0nS9EofjoiIiIiIyDVLIZqIyA5UFQXD3hLFcAghgLVkrRat7gxJll3pwxMREREREbnmKEQTEdlhqqJg6cwpfFmSNppY5wjek/d7VEXOzL4DCtJEREREREQuM81EExHZYYa9JXxZ0ux0cUmCMQaXJDQ7XXxZMuwtXelDFBERERERueYoRBMR2UGqsqQYDkkbzXUvTxtNiuGQqiwv85GJiIiIiIhc2xSiiYjsIDEGCAHr3LqXW+cghPp6IiIiIiIictkoRBMR2UGMsWAtwft1Lw/eg7X19UREREREROSy0VmYiMgOkqQpWatFmY/WvbzMR2StFkmaXuYjExERERERubYpRBMR2WFa3RlcmjLq9/BVRYwRX1WM+j1cmtLqzlzpQxQREREREbnmJFf6AEREZLUk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Ouoorrnmmim/xic/+Uk+97nPUalUdvt4V61aNeo9d/zxx3PFFVd0vn7LLbfw//1//99uvw5MLczfmVqthu/7XH755aNuP/vss1FKsX79+lG3r1q1ik996lNTfv7ZPNeZeuMb38jJJ5884dd+85vfoJTirrvuYvv27Zx88sksXbqUIAhYsWIF73//+6lWq3v0eKfzOxvgsssuw3Vd3ve+9427f/tnWimF4zj09vZy3HHH8bGPfYxNmzbN+Bhn49/ekZ6O/4eT/NswffJvw+T/p0L7Z8p1XZ588slRX9u0aROe5034O3mkb37zm/zmN7/hpptuYtOmTfT29u70d/Bs/xzvTZs2beJ1r3td5+/nn38+XV1dPPjgg1x33XWT3rYnSYgmxhm5VADozESbrUq0doDmOM7ONx96AfjF3ZqBBhBnDQaSCGPB88qUPJeyO/51862MGojwfR/X7cJxCiw5uI9SXwHjZES+A9ahoF18DVrBD1/YRTNwOLIY8oll+5FGGRuf/DG1+oN4Xg8r9n8v29JswvCsy5vanLmRbZye19PZlAn5IgHHSag3t/G9a65l644KYaGPd7/lTIqtBQPGpCTpjtb30sH3+3YaYGmtO3PW2osKYq15slqjmaS4jmJJuYve4tRCMGstWVbBmIg8wNv568+lKIpGLWGYrQ2ce4o2lt+t286P73iS363bjm5Vjs6lk08+mU2bNvHwww/zkY98hAsuuIAvfjEPiXt7e3frQ99se/nLX84NN9ww6rbrr7+eFStWjLv9hhtu4BWveMWUn3tvn6u1lne84x3U63V++MMfjvv6D37wA5Ik4ZxzzgHghBNO4JJLLuH+++/n5z//OdZaXvOa1+zR/6f9hBNO4JhjjuHiiy8e97X169dz/fXX8+53v7tz26WXXsqZZ55JtVrl97///YTPeckll7Bp0ybuvfdeLrroImq1Gs9//vP51re+tdNj+e1vf8u6des444wzdu+knoX++I//mLPOOmtWnuumm27i6KOP5sorr+Suu+7iXe96F+eeey4//elPR93nrW99K+9+97u5/fbbOe200zjttNO45557pvQaRx55JAceeCDf/va3Z+WYP/OZz7Bp0yZuv/12nvvc53LWWWdx0003AbBw4cJ9ZmlMuVzmOc95zoS/68b+Dnz00Ud57LHHpvU7cG+fa5IkvPvd7+aXv/wlGzZsGPf1Sy65hOc85zkcffTROI7Dqaeeyn/913/x0EMPcemll/KrX/1q3P/JMtem8zsb4Bvf+AYf+9jHuOyyyyYM3gAefPBBNm7cyC233MLHP/5xfvWrX3HkkUdy9913z9l5AJ3RHs808m/DzD3b/23YlWXLlo37b5NvfvObLFu2bJePXbduHYcddhhHHnkkixcvRim1x34Htxei7S2LFy8mCIY//69bt44Xv/jFrFy5kvnz50962x5ln2UqlYoFbKVS2duHss/aEiV2U5TYWGtrrbVRtMVG0WardTorz99sNm2lUrGNRmNWnm9ntE7thupG+1j1Sbu2OmA3NmO7LR5/HkmS2EqlYrdvf8w2GhttHG+3xhhrrbXGGHvP79bbf/jAz+znPvRz+4U/v87+3/deZ//1vdfZN190k13069vtAb++w963uWqHdjTtU09st7/57Uvsr647wD766L/bbXFqN0WJ3RIltpZmneedKmO0jeKtNoo22zStWmutrdfrtlKp2Gq1auN4h603N9nv/PRy+/G/u9B+4u/+zj6+YcOIaxB1vodxvHWX30etta1Wq7ZSqdh6vW6NMXYoSe26HYP2oa3b7aPbB2yUJNM4fmOTZMBG0WYbRVtslkXTOv/ZorXuXLdKpWKjaM8dR7PZtPfdd59tNpu79Tw/u3ujPfHzv7IrP/7Tzp8TP/8r+7O7N87SkY533nnn2VNPPXXUba9+9avtiSeeOOHXr7jiCnvkkUfaMAztvHnz7Ctf+Upbq9U6X//6179u16xZY4MgsIceeqi96KKLOl8jXwPc+XPSSSdZa629/vrr7XOf+1xbKpVsb2+vfeELX2jXr18/4fH+/Oc/t4DdtGlT57ZFixbZiy66yK5cubJz2yOPPGIBe/3111trrb3kkktsb2+vvfbaa+2aNWtsV1eXfe1rX2s3bhy+ttM51/Z9L7jgArtgwQLb3d1t3/ve99o4jjuP11rbz3/+83bVqlU2DEN79NFH2yuuuKLz9euvv94C9pprrrHHH3+89X3fXn/99fb000+3r3zlK8ed+0knnWTPOuusCa+LtdbeeeedFrBr166d9D4rV660n/vc5+y73vUuWy6X7YoVK+zXvva1Ufe566677Mtf/vLOeb/nPe+xQ0NDkz7nv/zLv9ienh5br9dH3X7++efbpUuX2izLrLX574oDDjjAXnvttfbjH/+4fc973jPuuQD7ox/9aNzt5557ru3u7rY7duyY9Dje97732T/6oz8addsdd9xhX/ayl9lyuWy7u7vt8ccfb2+55ZbO13/zm9/YF7/4xTYMQ7t8+XL7gQ98YNT7eePGjfb1r3+9DcPQrlq1yn7nO9+xK1eutP/8z/886pi/+tWv2lNOOcUWi0W7Zs0ae9NNN9mHH37YnnTSSbZUKtkXvOAF474vV111lT3uuONsEAR29erV9oILLrBpmo563q9//ev2tNNOs8Vi0R500EH2xz/+sbXW2kcffXTcz9N5551nrd31z+hkzj//fHvMMceMu339+vX2DW94g+3r67OlUskefvjh9uqrr97l8430+te/3r7rXe/q/P3MM8+0p5xyyqj7PP/5z7fvfe97O3+/6KKL7EEHHWSDILD77befPeOMM0bd/8ILL7QvfvGLd3lO7esymbHfzzRNbalUsn/1V3817uvGGHv++efbFStW2EKhYJcsWWI/8IEPdB4bRZH9yEc+YpcuXWpLpZJ93vOe1/kd1P55H/nn/PPPn9K5jvSJT3zCHnrooZ2/33fffba3t9d+/vOfH3WuF198sQ2CoPPvUvt31he/+EW7ePFiO2/ePPtnf/ZnNhnx7/10znXlypX2M5/5jD377LNtqVSyS5cutf/6r/866lgHBgbsu9/97s7vyJe//OX2jjvu6Hy9/Z77+te/bletWmWVUjZNU7to0SL72c9+dtRzDQ0N2XK5bP/93/990mvzla98xS5fvnzSr7d/bq688kr7spe9zBaLRXv00Ufbm266adT9fvCDH9jDDz/cFgoFu3LlSvuP//iPkz6ntXbKv7MfeeQRWywW7eDgoH3+859vv/Od74y6f/s9MjAwMOr2RqNhDz30UPuiF71o0mPYsWOHfdvb3mYXLFhgwzC0Bx10kL344outtZP/29t+T/zt3/6tXbJkiV21apW11trHH3/cvuUtb7G9vb22v7/fvulNb7KPPvqotTb/no19vvZ7/IknnrBnn3227e/vt6VSyZ5wwgn25ptv7jzumGOOsd/61rfsypUrbU9Pjz3rrLNstVrtnMOu/s201tqrr77aHnzwwTYMQ/uyl73MXnLJJRNes5Hk34bzrLXyb8PYc5ruvw0jta/zJz/5SXvwwQeP+tohhxxiP/WpT1mg83Mz1kknnTThz+RE76H2fxNN9nNs7c7/27t9rJdffrl96UtfaoMgsJdccskuH2ettb///e/tsccea4MgsCeccIL94Q9/aAF7++23T3rdtmzZYt/whjd0fja+/e1vT+u8Jvodc/7559s4ju373vc+u3jxYhsEgd1///3t5z//+QmPYWefy6aaFUmIJkbJjLGbWiGaMSYPcKLNNoo2Tzv8mUytVrOVSmXUh8m5sqX2lH2s+qR9rLrFbmzGdlOU2KgVDra1Q6MdOzbaSuVxG0VbrDFZ5+v1et1uHRi019+4zn7507+1X/4/eYD25xf8j13069vtol/fbn/w6FO2NhDZytaGfeCef7e/uu4A+5vfvMAORjW7KUrs5iix2QyvX5JUOgGYMcZGUdQKgnbYRmOLrTc32Wtu/Ln92Bc+az/2t5+zd91zT+exWVbvfP+SZIc1Ru/klfJrMTQ0ZCuViq3VajbLMjsQJ50A7YkdA6P+g3pX8gBtRydA03rvBGhZlnXOq1qtTuscZsNshGg/u3ujXTUiPGv/WdX6M1dB2kQh2pve9CZ7/PHHj/v6xo0bred59ktf+pJ99NFH7V133WUvuuiiTsDy7W9/2y5ZssReeeWV9pFHHrFXXnmlnTdvnr300kuttdb+4Q9/sID91a9+ZTdt2mS3b99u0zS1vb299qMf/ahdu3atve++++yll15qH3vssQmPt1arWd/37Xe/+11rrbX33nuv7enpsVEU2XK5bB955BFrrbXf+MY3bBiGnTD1kksusb7v21e96lX2lltusbfeeqs97LDD7Nve9rYJr8WuzvW8886z5XLZnnXWWfaee+6xP/3pT+3ChQvtX//1X3ee72//9m/tmjVr7LXXXmvXrVtnL7nkEhsEgb3hhhustcMfmI4++mj7i1/8wq5du9Zu377dXn311VYpNSpIXLdunVVK2V/84heTXpcPfehDdvXq1Tv93bty5Uo7b948e9FFF9mHH37YfuELX7CO49gHHnig8zxLliyxp59+ur377rvtddddZ1evXr3T/9jcvn27DYLAfvOb3+zcZoyxq1atGnU9rrvuOrt48WKbZZm9++67bXd397j/gJ8sRLv99tstYL/3ve9NehxHH320/bu/+7tRtx1xxBH2nHPOsffff7996KGH7Pe///3Oh/i1a9farq4u+8///M/2oYcesjfeeKM97rjj7Dvf+c7O41/1qlfZY4891t5888321ltvtSeddJItFovj/mNw2bJl9nvf+5598MEH7WmnnWZXrVplX/GKV9hrr73W3nffffbEE0+0J598cucx//M//2N7enrspZdeatetW2d/8Ytf2FWrVtkLLrhg1PMuX77cfve737UPP/yw/eAHP2jL5bLdvn27zbLMXnnllRawDz74oN20aZMdHBzc5ft2Zyb7oHTKKafYV7/61fauu+6y69atsz/5yU/sf//3f+/y+UZ60YteZD/ykY90/r5ixYpxH04+/elP26OPPtpaa+0tt9xiXde1/z975x0eRbX+8e/M9pIsCZCChASI9I5IiRgQkCbCVelCqOpVxIKAeJUE7HL5YbmIwIUACspFileahBJEmggJocSQQEJNCKRvsmXK+/tjs0Mmu6mAXGU+z5MH9syZM+ecmTln5jvved9169ZRRkYGnThxgj777DNZ/h07dpBWq630g0ltX5QsFgu9/vrrHts3bNhAvr6+tH37drp48SIdPXqUli1bJu03ZcoU6tGjB/3888+UlpZGCxYsIJ1OR+fOnSOHw0Gffvop+fr6UmZmJmVmZlJRUVG12lqWXbt2EQDpA8DixYtp8ODBdOTIEdmHhHHjxlGvXr2k31FRUeTr60svvPACJScn048//khGo1FW/5q0NTQ0lHx8fOjDDz+klJQU+vzzz0mlUsnGqL59+9KQIUPo2LFjdO7cOZoxYwbVrVuXcnJypPNjMplowIABdOLECTp58iQREc2cOZOaNm0qey5duXKlJEB54+rVqxQZGUljx46tsO/cL5MtWrSgrVu3UkpKCj3zzDMUGhoqiRS//fYbsSxL8+fPp5SUFIqNjSWDwSC9dHqjumP2O++8I4k5X3zxBT322GOycioS0YiIFi1aRADo+vXrXuvw0ksvUYcOHejYsWOUnp5OcXFx9N///peIvM+9RLfmsXHjxtHp06fp9OnT5HQ6qWXLljRp0iRKSkqis2fP0pgxY6h58+bkcDioqKiIRowYQQMGDJCuY3d6kyZNqGfPnnTgwAFKTU2l9evXSwJldHQ0mc1maW75+eefKSgoqEZz5qVLl0in09Hrr79Ov//+O33zzTcUGBhYpYimzA3K3OCtTXdCRPv111+pXr16dODAASJyia/169eX7rmKRLScnByaOnUqde/eXXZPViY2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HuyRb/jw4bDZbGjSpAmmTp2KzZs3SxZMp06dgiAIaNasmey87t+/v9IxsDptLYvRaESXLl2ksW7//v3o1asX1Go1evTogfj4eFy4cAGXLl3yGANbt24te2arbAysrK1uylr6un8nJycDcF3jVqsVdevWlfVHenq6rD9CQ0NRv359WTktWrRAjx49JF+LaWlpOHDggFdL3EWLFuHEiRP44YcfcP78ebz+uueKgvKUva+Cg4MBQDYGRkREyPJHREQgNTUVgiDgwIEDsvasXbu2WmN2XFwciouLMWjQIABAvXr10K9fP6/+JL1R1Rj497//Hd999x06dOiAWbNmST79qqJt27ayZ7STJ08iLS1NeuY0m83w9/eH3W6v9DpOTExEx44dZVZF5QkLC4OPzy1fwGWvv+rMmcnJyejatauszPLXoDeUuUGZG2rD7t270adPHzzwwAPw8fHBuHHjkJOTU+tya3v9VofKrmeF2qGIaPcrogiR5yGWPiyp5RsBlQoQCShjZl1TaiOi8SKhiHdF9NSTFToWYFkdVCqDLJ+Vd9XLoGJh0qrhbyToWRucPIFjLXDyIny0KvibNNCoGNhKH1LMKhZqlsE3mTmIzyuCnmXwectGUJdbxmnlBRTzHC5mLIERxQhtNAF6XRAAgEgEx+VDEIpL22cEw6hh56woFggMo0NdvT9U7K12uyODMgyD69evY9u2bWAYAZGRD+HBB8OrJaAVlApogEsIFB23JlWz2Sx78KgKQbCD4/IAEsAwKmg0fmDZux8Jk+M4jwACarW66h3vd8yBdzZfDenduzcSExORmpoKm82G1atXy0QUNyqVCnFxcdixYwdatWqFL774As2bN0d6ejqsVisAYPny5UhMTJT+Tp8+XWHkRjexsbE4fPgwevTogfXr16NZs2aV7tO7d2+cO3cOV69eRXx8vLR8xy2inT9/HpcvX/ZwqF3etJ1hmAqXMlTW1urg7o9t27bJ+uPs2bOyZY8AvPb1pEmTcOnSJezduxd79uzB5cuXMXHiRI98FosFDz74IB599FF8//33+P3337F58+ZK6+atH8QazAVl2zN//nwAriicjRo1wqpVq1BYWIhNmzbJXnjXrVsHu92Orl27Qq1WQ61WY/bs2fjll19kkagqwv1i3rhx4wrz1KtXz+OlICYmBmfOnMHgwYOxd+9etGrVSuof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coQbhaZp5OTkIIRAURRstoKtAg3+enjPnr2q9S6VpUuXEhISgs1mo0uXLvTu3bvAIMoNGzakffv21K9fn4ceeogZM2bo7nXZ2dkcPHiQwYMHExISov979dVX87mx5aVUqVIkJCTQqVMnunXrplvTFEWbNm10182kpCTdLbR169YB5RdbjQQFBVGtWjX975iYmAKtiooba946QUF/WtA2a9aMrKwsjh07xoEDB8jJyeGee+4JuB5z5szJdz0utp5SFIUBAwaQmJiIEAJN05g9ezYDBw7MZ3nSt29ftm7dyurVq6lZsya9evUqMJh5XvK6ekmSRLly5fTrsHfvXho2bBgQh6hFixZomqZbq+Udjz/Jw6BBg/j555/1sc2cOZPWrVtTvXp1ANLS0li0aJEezw3g0UcfLdSl82JEroVCUWuew+HIZ5n73HPPMWTIEDp06MDkyZMDrv327dtJTEwMGE+nTp3QNI3Dhw+zb98+TCYTjRs31ttUr16dyMjIfOfOe02jo6MBdBcvf5nT6SQjI0M/98SJEwPO7bdKyesWm7ff4OBgwsLCCp2zULJ5e6kMHz6cV199lRYtWjBu3Dh27NihH6tbt64uf5cuXfK1XbVqFQMHDmTGjBnUrVu3xOe85557qFy5MlWrVqVfv37MnTs3n7uwf9++uPxSadOmDWvXrsXj8VzSeuJn165ddO/enXHjxtGxY0e9/FLuY/AFp+/atSt16tQpMoh9QZQuXZpt27axbds2IiIidEviktzPeTl48CAej4cWLVroZWazmTvvvJO9e/cC8OSTTzJ//nxuu+02XnjhBdatW3dJshZG165dycrK4ueff2bmzJnX1QrNT8OGDalRowYLFy5k5syZ9OvX77rG79q+fTurVq0KWBdq164NELB2NWnS5JLaHTx4ELfbzV133aW3KVWqFLVq1SpSnr179wa0AQKsuy+Xa7FOGRgYGFxNDCXaLYbmTywACJGbYVMq+QNA3oQCAlAkcHt9L2ShZnu++kIIvKrPHccXC03KFwstW9VQBWR7Vd4/ehqAwWXMRFlM2K2lyPltk56J0JmTwu/7fNk4K1d+krCwBpjMCm6rk+BwK6XLhFO+bAT2UHC6U3G5nChKML/+upXDhw9jMpno06eP7mri0jTSLlKgSZKE0+lEVVUkScJutyNJ4PFcQFWdOJ0uFCUMiyXk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IxJ/KtDUcEH6gz6lX+i3Cpmvz0GoKqmuDBAqZlkhSLaRkXHSp0CTzQgtiK+//hpVValVq5Yex6QwBZrX69UVaBaLBUXR8HjSAA1NyKiqHUkyYbVar2sMjKLIazVns9luGrkMrj5hHTtSYep7mC56sTFFR1Nh6nuEXaWXxSshLCyMn3/+mXvvvZeaNWsyZswYpkyZosdCGjJkCJ999hmzZs2ifv36tG7dmsTERN0SzWQy8f777/Ppp59Svnx5unfvTlBQEL///js9e/akZs2aDB06lKeeeorHH3+8SFnatm1LZmamft/7ad26NZmZmUVm0bsaYwWfC6PflbJ379784x//CIhlNGnSJF555RXeeOMN4uPj6dy5M99++22RLol56dmzJ1arlaCgIHr06BFwzGazsWbNGu69916qV69O7969CQ0NZd26dbp12OUQFBTEDz/8wIULF7jjjjt48MEHad++PR9++GGxbStVqkSHDh1ITU0NcIP6/PPPqVOnTj4FGvish8+cOcN3332nlw0cOJCYmBhq167Nk08+SUhICL/99huPPPJIkefv27cvu3fv1t3cFEXh/Pnz9O/fX48X16VLFz2DaoMGDVi9ejV//PEHLVu2pFGjRowdOzZAWTJnzhyio6Np1aoV999/P4899hihoaFXnBW5U6dOLF26lB9//JE77riDpk2b8u677xZrbZiXChUqMGHCBF566SWio6MZNmxYiebtxQwZMoRGjRrx6aef8scff9CoUSMaNWrEyZMnAZ+l21NPPaXP4Zo1axaZoW/27Nnk5OTwxhtvEBMTo/974IEH9DrNmzdn3rx5TJ8+nYYNG7Jw4UKWLFlCvXr1AIiIiGDRokW0a9eO+Ph4PvnkE7788ss/P5Q5nSxZskRXTl0pl7qeLFy4kLNnz/LFF18EjPGOO+7I13dR9/GWLVvYsGEDO3fupHr16gF9HTt2rFB5mzZtyubNm6lVqxZPPfUUderUoXnz5nz55Ze8++67PPnkk8Dl3c+TJ0+mZ8+e9OvXj8aNG3PgwAF++OEH3TLMYrEwevRoGjRoQKtWrVAUhfnz5xd1eUlKStLnlf9f3kzGeSldurSugL+a5J3b/n+F7TMRERFFWn5dDUaOHImiKNSpU4eoqCiOHj1K+fLlWbt2Laqq0rFjR+rXr88zzzxDREREkQqvkrR7++23admy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ADRp0oRZs2axd+9efvjhB4QQdOzY8bpaYTVp0oSGDRsyc+bMfMeSk5NZtWoVgwcP1ssSExPp1asXGRkZbNiwocA+Z82aRUpKCrt372batGlkZWVx1113MWfOnCJl+eWXXzh48CA9e/a8skHdggwaNIjevXtflb7WrVtHgwYN+Prrr9mxYwcDBw6kf//+LF26NKBOnz59GDx4MFu3bqVHjx706NGDXbt2legc9erV+3/2zjssqqP749+7felYAAugggpYCLaoRJGIgBqjUWNHQCzx1WhMNDG+KlhTXo1JjD0Kdk1srz+NGgtYELuoCEFEsGIFgWULu3vP7491b1iplmheM5/n2UfvzNyZM7Mzw95zz5wDDw8PrFu37oVkDQwMRGJiooXSqbz9JD4+HoGBgQBMymMnJyesW7cOly9fxr///W98+eWX+OmnnwCgyuv4zp07uHPnDubNm4eUlBTExcVh7969FmumLE6dOoUWLVogLS0NixYtQkpKChISEjB8+HAsWbIEly9ffqFxedW4uroiNjbWIu3EiRO4e/curK2tX5NUL46Li4tF9PbMzEy88847cHd3R/Xq1ctNYzAYDMZrhv5h5OfnEwDKz89/3aK8MtR6DV0vuE23VXdL5RUWFlJ+fj49UmvotlpN91V3SKu9SzxvJCIinU5H+fn5VFCQTxpNzpM8A624cZ+cD52nZscuUUbBI8ovvEkPH2bRjRu/0YGDDehQfBPKzb1BBkMxabV36VHRHbqtLqTdRw/RnDmT6D//mUqPH+cRERHP84Icubl36cGDDHr48CoVF6te5TA9FwaDgQoKCig/P5/UavXrFofxN0Wj0VBqaippNJrnruPKiUSa1697uZ8rJxJfosR/Eh4eTj179rRI69KlC7Vt27bM/F9//ZWaNm1KCoWCqlWrRp07dyaV6s+1vGLFCvLy8iK5XE6NGzemRYsWCXl44sLR/AkICCAiovj4eGrdujVZWVmRvb09tW/fnrKzs8uUd9++fQSAcnJyhDRnZ2datGgRubu7C2nXrl0jABQfH09ERLGxsWRvb0979+4lLy8vsra2ppCQELpz5065Y1FRX81lY2JiqEaNGmRra0ujRo0inU4n3G80Gmnu3LlUr149UigU1Lx5c/r111+F/Pj4eAJAv/32G7Vo0YKkUinFx8dT7969qXPnzqX6HhAQQP37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jhEjRsDc3BwGBgbo2bMnbt26pVEnKChIiqt///749ttv8/3gIJPJYG1tDVtbW/Ts2RMTJ07EsWPHkJqaKj2WuXfvXjRq1Aj6+vq4f/9+sfYNAHv27JHagoeHh8b/9e/cuYN+/frBysoKRkZG+PDDD3Hs2DGtx61WVDvLe60DQPPmzTV6KZb2+iciordXpSbRatSoAR0dHfz9998a5X///Tesra0LXMfa2rpE9fX19WFiYqLxet/I5XLo6OhUicdTiN4XQggkZ2VV+EsIUebYVSqVRjJILT4+HkOHDsWoUaMQHR2N8PBwfPzxx9I+t23bBn9/fyxcuBDR0dFYtGgR5syZg82bNwOANDPzsWPHEB8fj127diEzMxOenp5wdXXFlStXcObMGYwdO7bQXrcdOnSAnp4ewsLCAABRUVFITU2Fr68vnj59itjYWAA5vUaUSiXat28vrZuSkoKlS5di69at+P3333H//n1MmzatwP1oO1YgZ/wt9bIff/wRu3btwty5c6XlixcvxpYtW7Bu3Tpcv34dkydPxqefforffvtNY18zZszAkiVLEB0djaZNm8LX1xcnTpzQSCTevXsXv//+u0avrtySk5MRHByMOnXqaO2RvWzZMrRu3RoRERH497//jX/961/Sl//k5GR4eHjA3Nwc58+fx88//4xjx47Bz8+v0O0NHz4caWlpGglCIQQ2b94MHx8f6W9XWFgYUlJS4O7ujk8//RTbt28vMrmT2+TJk/Hq1SscPXq00DonT55E69at88VWs2ZNnD9/HhcvXsSMGTOkma3v3LmDHj16YMCAAbhy5Qp27NiBP/74Q+NYR4wYgUePHiE8PBy//PILNmzYUOAjavPnz8eIESMQGRmJhg0bYtiwYRg3bhxmzpyJCxcuQAihsd2TJ09ixIgRmDRpEqKiorB+/XqEhIRg4cKFGtudO3cuBg0ahCtXrqBXr14YPnw4nj17Bnt7e/zyyy8AgJiYGMTHx+O///1vsdptSX322WdIS0vD77//jqtXr+Lrr78u8SRHL1++lJK/gPahOy5cuICJEydi3rx5iImJwaFDh9CpUyeN+m3atMG5c+eQlpZWyiMDnJycYGtrK91PXr16hUuXLuGTTz5B7dq1pXhOnz6NtLS0fEm0r776CsuWLcOFCxegq6uLUaNGFbif4tznbt++jZ9++gn/7//9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HR0hhBDPnj0TXl5ewtTUVKhUKuHh4SFu3rypsY0NGzYIOzs7oVKphKenp1iwYIGwtrbWOH957695z29By7XtW73eL7/8IurWrSv09fWFu7u7uHfvnlQnNjZWuLm5CZVKJezt7cWqVauEq6urmDRpUqHxqGlrZy9fvhSDBw8WJiYmwt7eXoSEhBQ4sUBprn8iInozymNiAZkQZZjX/B2UmJgIU1NTvHz5UhqrhYjoTXr9+jViY2NRp06dfAOb0/vJx8cHL168wJ49eyo7FMpl+vTpSExM1BiPrTw9ePAA9vb2OHbsGLp27fpG9kHarV27Frt378aRI0eKrBcYGIi4uLh8PTnfNoGBgdizZ4/GeINvszFjxuDGjRs4efJkZYdCRESkoajvZcXNFfFxTiIiIqoSvvrqK6xZswbZ2dn5ZjItjRMnTiApKQlNmjRBfHw8vvjiC9SuXRudOnUqh2iptPT09LBy5crKDqPKWLp0Kbp16wZDQ0McPHgQmzdvxpo1ayo7LCIiojeCSTQiIiKqEszMzDBr1qxy215GRgZmzZqFu3fvwtjYGC4uLti2bVuZxtujshs9enRlh1ClnDt3Dt988w1evXqFunXrYsWKFe/EZ3Dy5En07Nmz0OXqWU+JiIhy4+OcRERvGB/nJCJ6+4SHh+PFixfw9PSs7FCoEqSmpuLhw4eFLi/L7LhERPR24uOcRERERESl0Llz58oOgSqRSqViooyIiEqs7AOCEBERERERERERveeYRCMiqiBV7Ol5IiIiIiKit0Z5fB9jEo2I6A1TDzKekpJSyZEQERERERFVTervY2WZBIpjohERvWE6OjowMzPDP//8AwAwMDCATCar5KiIiIiIiIjef0IIpKSk4J9//oGZmRl0dHRKvS0m0YiIKoC1tTUASIk0IiIiIiIiqjhmZmbS97LSYhKNiKgCyGQy2NjYwNLSEhkZGZUdDhERERERUZWhp6dXph5oakyiERFVIB0dnXK5eRMREREREVHF4sQCREREREREREREWjCJRkREREREREREpAWTaERERERERERERFpUuTHRhBAAgMTExEqOhIiIiIiIiIiIKps6R6TOGRWmyiXRXr16BQCwt7ev5EiIiIiIiIiIiOht8erVK5iamha6XCa0pdneM9nZ2Xj06BGMjY0hk8kqO5xykZiYCHt7e/z1118wMTGp7HCIisT2Su8Stld6l7C90ruE7ZXeFWyr9C5hey09IQRevXoFW1tbyOWFj3xW5XqiyeVy1KxZs7LDeCNMTEx4odA7g+2V3iVsr/QuYXuldwnbK70r2FbpXcL2WjpF9UBT48QCREREREREREREWjCJRkREREREREREpAWTaO8BfX19BAQEQF9fv7JDIdKK7ZXeJWyv9C5he6V3CdsrvSvYVuldwvb65lW5iQWIiIiIiIiIiIhKij3RiIiIiIiIiIiItGASjYiIiIiIiIiISAsm0YiIiIiIiIiIiLRgEo2IiIiIiIiIiEgLJtHeEatXr0bt2rWhVCrRtm1bnDt3rsj6P//8Mxo2bAilUokmTZrgwIEDFRQpUcna6/Xr1zFgwADUrl0bMpkMy5cvr7hAiVCy9hoUFISOHTvC3Nwc5ubmcHd313o/JipPJWmvu3btQuvWrWFmZgZDQ0M0b94cW7durcBoqaor6f9f1bZv3w6ZTAZPT883GyDR/ypJWw0JCYFMJtN4KZXKCoyWqrqS3ltfvHiBzz77DDY2NtDX14eTkxPzA2XAJNo7YMeOHZgyZQoCAgJw6dIlNGvWDB4eHvjnn38KrH/69GkMHToUvr6+iIiIgKenJzw9PXHt2rUKjpyqopK215SUFNStWxdLliyBtbV1BUdLVV1J22t4eDiGDh2KsLAwnDlzBvb29ujevTsePnxYwZFTVVTS9lqtWjV89dVXOHPmDK5cuYKRI0di5MiROHz4cAVHTlVRSdurWlxcHKZNm4aOHTtWUKRU1ZWmrZqYmCA+Pl563bt3rwIjpqqspO01PT0d3bp1Q1xcHHbu3ImYmBgEBQXBzs6ugiN/jwh667Vp00Z89tln0vusrCxha2srFi9eXGD9QYMGid69e2uUtW3bVowbN+6NxkkkRMnba24ODg7iu+++e4PREWkqS3sVQojMzExhbGwsNm/e/KZCJJKUtb0KIUSLFi3E7Nmz30R4RBpK014zMzOFi4uL2Lhxo/D29hb9+vWrgEipqitpWw0ODhampqYVFB2RppK217Vr14q6deuK9PT0igrxvceeaG+59PR0XLx4Ee7u7lKZXC6Hu7s7zpw5U+A6Z86c0agPAB4eHoXWJyovpWmvRJWlPNprSkoKMjIyUK1atTcVJhGAsrdXIQSOHz+OmJgYdOrU6U2GSlTq9jpv3jxYWlrC19e3IsIkKnVbTUpKgoODA+zt7dGvXz9cv369IsKlKq407XXv3r1o3749PvvsM1hZWeGDDz7AokWLkJWVVVFhv3eYRHvLPXnyBFlZWbCystIot7KyQkJCQoHrJCQklKg+UXkpTXslqizl0V6//PJL2Nra5vvhgqi8lba9vnz5EkZGRlAoFOjduzdWrlyJbt26velwqYorTXv9448/8P333yMoKKgiQiQCULq22qBBA2zatAm//vorfvjhB2RnZ8PFxQUPHjyoiJCpCitNe7179y527tyJrKwsHDhwAHPmzMGyZcuwYMGCigj5vaRb2QEQERG9i5YsWYLt27cjPDycAwrTW8vY2BiRkZFISkrC8ePHMWXKFNStWxedO3eu7NCIJK9evYKXlxeCgoJQo0aNyg6HqEjt27dH+/btpfcuLi5wdnbG+vXrMX/+/EqMjCi/7OxsWFpaYsOGDdDR0UGrVq3w8OFD/Oc//0FAQEBlh/dOYhLtLVejRg3o6Ojg77//1ij/+++/Cx2E3draukT1icpLadorUWUpS3tdunQplixZgmPHjqFp06ZvMkwiAKVvr3K5HPXq1QMANG/eHNHR0Vi8eDGTaPRGlbS93rlzB3Fxcejbt69Ulp2dDQDQ1dVFTEwMHB0d32zQVCWVx/9d9fT00KJFC9y+fftNhEgkKU17tbGxgZ6eHnR0dKQyZ2dnJCQkID09HQqF4o3G/D7i45xvOYVCgVatWuH48eNSWXZ2No4fP67xC0hu7du316gPAEePHi20PlF5KU17JaospW2v33zzDebPn49Dhw6hdevWFREqUbndX7Ozs5GWlvYmQiSSlLS9NmzYEFevXkVkZKT0+uijj+Dm5obIyEjY29tXZPhUhZTHvTUrKwtXr16FjY3NmwqTCEDp2muHDh1w+/Zt6YcJALh58yZsbGyYQCutyp7ZgLTbvn270NfXFyEhISIqKkqMHTtWmJmZiYSEBCGEEF5eXmLGjBlS/VOnTgldXV2xdOlSER0dLQICAoSenp64evVqZR0CVSElba9paWkiIiJCRERECBsbGzFt2jQREREhbt26VVmHQFVISdvrkiVLhEKhEDt37hTx8fHS69WrV5V1CFSFlLS9Llq0SBw5ckTcuXNHREVFiaVLlwpdXV0RFBRUWYdAVUhJ22tenJ2TKkpJ2+rcuXPF4cOHxZ07d8TFixfFkCFDhFKpFNevX6+sQ6AqpKTt9f79+8LY2Fj4+fmJmJgYsW/fPmFpaSkWLFhQWYfwzuPjnO+AwYMH4/Hjx/D390dCQgKaN2+OQ4cOSQMK3r9/H3L5/3UqdHFxQWhoKGbPno1Zs2ahfv362LNnDz744IPKOgSqQkraXh89eoQWLVpI75cuXYqlS5fC1dUV4eHhFR0+VTElba9r165Feno6Bg4cqLGdgIAABAYGVmToVAWVtL0mJyfj3//+Nx48eACVSoWGDRvihx9+wODBgyvrEKgKKWl7JaosJW2rz58/x5gxY5CQkABzc3O0atUKp0+fRqNGjSrrEKgKKWl7tbe3x+HDhzF58mQ0bdoUdnZ2mDRpEr788svKOoR3nkwIISo7CCIiIiIiIiIiorcZf/4hIiIiIiIiIiLSgkk0IiIiIiIiIiIiLZhEIyIiIiIiIiIi0oJJNCIiIiIiIiIiIi2YRCMiIiIiIiIiItKCSTQiIiIiIiIiIiItmEQjIiIiIiIiIiLSgkk0IiIiovdceHg4ZDIZXrx4UdmhlCuZTIY9e/ZUdhhERERURTCJRkRERPSOCAkJgZmZWYnXc3FxQXx8PExNTYu9jo+PDzw9PUu8rzchMDAQzZs3z1ceHx+Pnj17VnxAREREVCXpVnYARERERKRdRkZGqddVKBSwtrYux2jKR3p6OhQKRanXfxuPiYiIiN5f7IlGRERElEft2rWxfPlyjbLmzZsjMDAQQM5jhBs3bkT//v1hYGCA+vXrY+/evRr1r1+/jj59+sDExATGxsbo2LEj7ty5Iy3fuHEjnJ2doVQq0bBhQ6xZs0ZaFhcXB5lMhh07dsDV1RVKpRLbtm3DyJEj8fLlS8hkMshkMimerVu3onXr1jA2Noa1tTWGDRuGf/75R9pe3sc51T3aDh8+DGdnZxgZGaFHjx6Ij48HkNPza/Pmzfj111+lfYWHh6NLly7w8/PTOM7Hjx9DoVDg+PHjxTqv8+fPx4gRI2BiYoKxY8cCAL788ks4OTnBwMAAdevWxZw5c6SkYUhICObOnYvLly9LsYSEhEifQ+7HOa9evYouXbpApVKhevXqGDt2LJKSkrTGRURERFQcTKIRERERlcLcuXMxaNAgXLlyBb169cLw4cPx7NkzAMDDhw/RqVMn6Ovr48SJE7h48SJGjRqFzMxMAMC2bdvg7++PhQsXIjo6GosWLcKcOXOwefNmjX3MmDEDkyZNQnR0NNzc3LB8+XKYmJggPj4e8fHxmDZtGoCcXmrz58/H5cuXsWfPHsTFxcHHx6fI+FNSUrB06VJs3boVv//+O+7fvy9tb9q0aRg0aJCUWIuPj4eLiwtGjx6N0NBQpKWlSdv54YcfYGdnhy5duhTrvC1duhTNmjVDREQE5syZAwAwNjZGSEgIoqKi8N///hdBQUH47rvvAACDBw/G1KlT0bhxYymWwYMH59tucnIyPDw8YG5ujvPnz+Pnn3/GsWPH8iX9iIiIiEqLj3MSERERlYKPjw+GDh0KAFi0aBFWrFiBc+fOoUePHli9ejVMTU2xfft26OnpAQCcnJykdQMCArBs2TJ8/PHHAIA6deogKioK69evh7e3t1Tv888/l+oAgKmpKWQyWb7HGEeNGiX9u27dulixYgU+/PBDJCUlwcjIqMD4MzIysG7dOjg6OgIA/Pz8MG/ePACAkZERVCoV0tLSNPb18ccfw8/PD7/++isGDRoEIKenmI+PD2QyWbHOW5cuXTB16lSNstmzZ0v/rl27NqZNm4bt27fjiy++gEqlgpGREXR1dYt8fDM0NBSvX7/Gli1bYGhoCABYtWoV+vbti6+//hpWVlbFio+IiIioMEyiEREREZVC06ZNpX8bGhrCxMREeoQyMjISHTt2lBJouSUnJ+POnTvw9fXFmDFjpPLMzMx8A/+3bt26WLFcvHgRgYGBuHz5Mp4/f47s7GwAwP3799GoUaMC1zEwMJASaABgY2Oj8QhoQZRKJby8vLBp0yYMGjQIly5dwrVr1/I9ylqUgo5px44dWLFiBe7cuYOkpCRkZmbCxMSk2NsEgOjoaDRr1kxKoAFAhw4dkJ2djZiYGCbRiIiIqMyYRCMiIiLKQy6XQwihUZZ3YP+8CTKZTCYlr1QqVaHbVo/RFRQUhLZt22os09HR0XifOyFUGPVjjB4eHti2bRssLCxw//59eHh4ID09vdD1Coo/7zEXZPTo0WjevDkePHiA4OBgdOnSBQ4ODlrXU8t7TGfOnMHw4cMxd+5ceHh4SD34li1bVuxtEhEREVUEJtGIiIiI8rCwsJAG2QeAxMRExMbGFnv9pk2bYvPmzcjIyMiXrLKysoKtrS3u3r2L4cOHlyguhUKBrKwsjbIbN27g6dOnWLJkCezt7QEAFy5cKNF2i7svAGjSpAlat26NoKAghIaGYtWqVWXaz+nTp+Hg4ICvvvpKKrt3716xYsnN2dkZISEhSE5OlhJ1p06dglwuR4MGDcoUIxERERHAiQWIiIiI8unSpQu2bt2KkydP4urVq/D29s7XS6wofn5+SExMxJAhQ3DhwgXcunULW7duRUxMDICcSQkWL16MFStW4ObNm7h69SqCg4Px7bffFrnd2rVrIykpCcePH8eTJ0+QkpKCWrVqQaFQYOXKlbh79y727t2L+fPnl+n41fu6cuUKYmJi8OTJE42eeKNHj8aSJUsghED//v3LtJ/69evj/v372L59O+7cuYMVK1Zg9+7d+WKJjY1FZGQknjx5ojGxgdrw4cOhVCrh7e2Na9euISwsDBMmTICXlxcf5SQiIqJywSQaERERUR4zZ86Eq6sr+vTpg969e8PT01Nj/DBtqlevjhMnTiApKQmurq5o1aoVgoKCpF5po0ePxsaNGxEcHIwmTZrA1dUVISEhqFOnTpHbdXFxwfjx4zF48GBYWFjgm2++gYWFBUJCQvDzzz+jUaNGWLJkCZYuXVqm4weAMWPGoEGDBmjdujUsLCxw6tQpadnQoUOhq6uLoUOHQqlUlmk/H330ESZPngw/Pz80b94cp0+flmbtVBswYAB69OgBNzc3WFhY4Mcff8y3HQMDAxw+fBjPnj3Dhx9+iIEDB6Jr165l7ilHREREpCYTxRn8goiIiIjof8XFxcHR0RHnz59Hy5YtKzscIiIiogrBJBoRERERFUtGRgaePn2KadOmITY2VqN3GhEREdH7jo9zEhEREVGxnDp1CjY2Njh//jzWrVunsezkyZMwMjIq9EVERET0rmNPNCIiIiIqs9TUVDx8+LDQ5fXq1avAaIiIiIjKH5NoREREREREREREWvBxTiIiIiIiIiIiIi2YRCMiIiIiIiIiItKCSTQiIiIiIiIiIiItmEQjIiIiIiIiIiLSgkk0IiIiIiIiIiIiLZhEIyIiIiIiIiIi0oJJNCIiIiIiIiIiIi2YRCMiIiIiIiIiItLi/wONIWMZjzVB/wAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plt.figure(figsize=(15, 7))\n", "\n", "show_for_all_datasets = True\n", "\n", @@ -706,7 +1046,7 @@ "):\n", " group = uncertainty_results.loc[group_loc]\n", " color = f'C{i}'\n", - " has_t = not pd.isna(group['t'].values[0])\n", + " has_t = not pd.isna(group['t'].values[0]) and group['t'].nunique() > 1\n", " \n", " if not pd.isna(additional_pipeline):\n", " label = f'{base_pipeline.replace(\" with scores\", \"\")} | {additional_pipeline} | {method}'\n", @@ -718,6 +1058,7 @@ " assert group.audio_name.nunique() == len(group)\n", " xs = [m[x_statistics] for m in group.metrics]\n", " ys = [m[y_statistics] for m in group.metrics]\n", + " assert len(xs) == len(name_to_transcription)\n", " if show_for_all_datasets:\n", " plt.scatter(xs, ys, alpha=0.1, color=color)\n", " plt.scatter([np.mean(xs)], [np.mean(ys)], label=label, color=color)\n", @@ -736,6 +1077,7 @@ "\n", " xs = np.array(xs).T # shape: (n_audios, n_t_values)\n", " ys = np.array(ys).T # shape: (n_audios, n_t_values)\n", + " assert len(xs) == len(name_to_transcription)\n", "\n", " if show_for_all_datasets:\n", " for _xs, _ys in zip(xs, ys):\n", @@ -748,6 +1090,53 @@ "plt.show()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visual analysis of uncertainty highlighting" + ] + }, + { + "cell_type": "code", + "execution_count": 124, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Вторая строка фактически была в очень плохом состоянии, но удалось однако же все-таки ее практически целиком восстановить. Я не буду вам выписывать все скобки неполной видимости, это не очень в данном случае существенно, поскольку в конечном счете результат совершенно надежный {остался|оказался} восстановлен. И читается следующее. Адресат. Вот практически все, что сохранилось от этой грамоты, это адресная формула. поклон от {Клименте|элементе} и от {Марьи|марья} к Петку {Копарину. Имя Петко|кабаринаимя пятко} находится далеко. скажем своде тупикова которым постоянно пользуемся своде древнерусских имен {петка|пятко} упоминается 11 раз то есть один из разных персонажей но это очень понятно это одно из {элементов а|имен того} типа как какой-нибудь шестак {3 2|третей второй} и так далее когда Долго не думая, детей называли просто по счету появления, и больше ничего. Что касается опарина, то, конечно, он происходит от имени опара. Но опара – это такое тесто, вылезающее из катки. Я очень себе представляю, какого человека должны были награждать прозвищем опара. {В} всяком случае, это имя вполне... и прозвище, и имя, вполне {существующие|существующий} в русской традиции, и {фамилии|фамилия} хорошо известные. Казалось бы, больше ничего из этого особенного извлечь не можем, кроме того, что имя Пятко, которое раньше не встречалось, внесем в {словарь|словари}, и все. Но нет. Это из тех замечательных случаев, когда... так сказать, покопавшись в фонде уже имеющихся материалов, мы обнаруживаем какую-то перекличку. В данном случае эта грамота оказалась в полной перекличке с грамотой, найденной 60 лет назад под номером 311. Грамота под номером 311 гласит... Господину своему Михаилу Юрьевичу. Михаил Юрьевич – посадничий сын, того времени очень важный боярин, начало 15 века. «Христиане твои черенщане челом бьют». Дальше я дам перевод, чтобы не заниматься… лишними деталями ты отдал {пашин|пашенку} {куб крем цук|климцу} опарину а мы его не хотим не соседний человек {в больном|волен} бог {длительное|да и ты} {такая|такой} {замечательная|замечательной} но это формула очень {хорошо известная|хооизвестный} {вольн|волин} бог {дайте|да} То есть смысл стоит в том, что ты один, по сути дела, отвечаешь за то, как ты решишь дело. Климцу опарину. Точное сочетание имени, которое у нас встретилось в {полной|полном} виде. Заметьте, там они называют его Климцом, поскольку он им не нравится. Вообще он такой человек, {которого|который} они хотят, чтобы ему никаких пашинок никто не давал. Здесь он называет себя более официально, от Климентии, но совершенно ясно, что одно и то же имя. И фамилия Парин, которому он пишет просто без всяких там {господину|господин} и так далее, совершенно очевидно, как естественно было бы писать своему родственнику. И тогда довольно ясно, что это начало семейного письма, к брату хозяина. И таких семейных {мероприятий есть некоторое|съесть мехри} количество, они очень похожи по типу. Они бывают или просто приглашение приехать, или иногда поздравление с чем-то. И, пожалуй, тут тоже уместно привести {точный|точные} {пример|примеры} того, Очень похожие примеры того, как можно себе представить, что там дальше было в этой грамоте. К сожалению, в данном случае конец нам неизвестен, пока что его не нашли. Например, грамота того же времени. «Поклон от Гаврилы {Постни|посдни}, зятю моему куму Григорию и сестре моей Улите». Ну, очевидно, мужу и жене тоже. Поехали бы вы в город, в город, это, разумеется, в Новгород, то есть это письмо... {послана|посла} куда-то за пределы города. Поехали бы в город к радости моей, а нашего слова не забыли бы. Дай Бог вам радость. Вот, пожалуй, такое очень, кстати, тоже очень известное письмо, номер {497|четыреста девяносто семь}, показывающее, что такого рода записки тоже в то время вполне могли посылать. и другое письмо тоже того же времени на этот раз и старые русы старые русы номер 10 тоже прочту Кстати, еще идет по образцу XII века, а не XIV, что показывает, что это могло держаться. Поклон от Оксинии и Анании. Анания – мужское имя, Оксиния, естественно, женское. Поклон от Оксинии и Анании к Родивону и сестре моей Татьяне. «Пойдите в город, опять-таки, конечно, в Новгород, к сей неделе, то есть к этому воскресенью». Дальше фраза, на которую я обращаю внимание, потому что она еще нам понадобится для дальнейшего. «Давать мне дочь, а сестре моей приставничать». Это надо перевести. То есть я... Мне предстоит выдавать свою дочь, а сестре моей предстоит быть распорядительницей на свадьбе. Все эти термины очень хорошо прослеживаются. Ну и так далее. А я господину своему Родиону и своей сестре много челом бью. Вот примерно тот тип писем, который явно совершенно представленный этим письмом, но вот с таким снова обнаруженными, {ну|но} теперь уже двумя персонажами этим писем. {Кремцом|кримцом} {опаренным|опариным}, про которого мы кое-что знаем из 311 грамоты, давным-давно найденной, и его {братья|брате}, потому что они находились, так сказать, еще в таких вот отношениях взаимных. дружили семьями, скажем так, {сейчас будет} названо вероятно. Ну, об этом... Об этом достаточно, пойдемте дальше. Дальше мы попадаем в тот самый комплекс писем середины второй половины {XIV|четырнадцатого} века. Ну, второй половины, нет, середины там мало что. с сильно пересекающимися именами. Итак, номер {98|девяносто восемь}. Ну, опять-таки, вас не удивит, что я скажу, что грамоты целы. {Семи|семитшесть} строк... Шесть строк. Да, я не сказал вам, что на той грамоте, где перечислялись все... кто дал рубль и полтину, имелась запись на обороте, а именно первая половина алфавита. Такое упражнение довольно часто встречается. Терпения, правда, не хватило у писавшего дойти дальше буквы «К». До этого он {всё|все} успел записать. Это бывает. Это такое очень естественное занятие для человека, который... умел грамоте так себя реализовать в свободный момент. Итак, 1098 Здесь тоже большое письмо, {6|шесть} строк, причем более длинных, чем там на лицевой стороне, и еще одна строка на обороте. Ну вот, почитаем. Вас, {уважение|уже не}, должно удивлять. В это время это совершенно нормальное начало писем. Вот если бы такое встретилось в в письме {XII|двенадцатого} века, это была бы совершенная сенсация, чтобы {начиналось|начиналась} поклон. Тогда пришлось бы десять раз контролировать археологов, не ошиблись ли они, и на самом деле не является ли эта грамота более поздней, чем такое предполагается. Но таких случаев не было, это я говорю в абстрактном виде. Итак, поклон... Вот выступает первое лицо, который будет еще нам встречаться. Да. {пожалуй|этожалуйт} я {с ним еще|не} начальная формула Совершенно стандартные имена тоже обычного {набор|набора} из обычного набора вот дальше уже идет содержание как всегда, некоторым некоторой драматической основы, поскольку, если не считать вот таких пригласительных, ласковых писем, которых другого содержания не было, кроме того, что «дай Бог вам радость» или что-нибудь в этом духе, то всегда нужно было что-то такое расхлебать, что было неудовлетворительно для писавшего. Так и здесь. Вот чем он недоволен. Заметьте, XIV век, уже легко вам понимать текст. Это Не всегда, конечно. Бывают и неприятные казусы даже с {XIV|четырнадцатым} веком. Но, тем не менее, пока что вы должны понимать все совершенно без всякого затруднения. Со скоростью, так сказать, чтения. Верно? {Уж}... Это, скорее всего, уже «уж» читалось. Но дело в том, что наше с вами «уже» раньше имело ударение «уже». И, соответственно, «уж» очень легко получалось из этого «{уже|ужа}». Но по смыслу уже к вам шлю третью, обращаю ваше внимание, именно такая была древняя форма, это была полная форма, {но} не краткая, {третьюю|третью} третью грамоту. А вы мне подскажите, какое дальше будет слово? А, правильно, конечно, совершенно ясно. Зачем иначе это писать, если не касается того, что... Ну, абсолютно очевидно. Может быть, даже {вы} еще одно слово угадаете? А вы, конечно, смотрите, как все замечательно. Правильно. Совершенно справедливо. Ну, дальше уже... {здесь|есть} разнообразие, {а там|это} совершенно правильно. Комни... Ну, вот это вот первый случай, где у вас {ядь|ять} реализован в виде и. Для {XIV|четырнадцатого} века вещь совершенно нормальная, так что не заявляйтесь, это будет еще и не раз, и не только в этой грамоте. Значит, ко мне, это ко мне с {ядьем|ятем}. В высшей степени все естественно. Кстати, обращаю ваше внимание, что сейчас бы мы сказали, вы ко мне не присылаете. Это нормальный русский оборот, вот то, что я предпочитаю называть {presence|презенс} напрасного ожидания, который требует совершенного вида. Вот как в известной форме, там денег все не соберем, а не собираем, это в точности. этот же тип {синтаксис|синтекс}, который в древних текстах довольно часто. А вы ко мне не пришлете. Это не будущее время, конечно, а то, что сейчас выразилось бы. А вы ко мне не присылаете. Не призываете, но ясно того, что... материального. Придется немножко мне здесь... Вы, конечно, думаете о накладных современных, но это немножко будет поспешно. Нет, он написал это правильно, конечно, было бы через ЕР, но он написал через ЕР, простите меня. Нет, пока еще правильно. Потом он эту ошибку сделает. Не ошибку, а вариативность. Дважды написал чуть-чуть {различным|различно}, потому что это слово еще повторится. Вот такая жалоба. А что это за накладное серебро? Ну, я говорю накладное с нынешним ударением, конечно, тогда это было {ударение|дарение} накладного, без всякого сомнения, но как вы думаете? Будьте ближе к нормальным материальным интересам {тогдашнего}. Что такое {осталось|остался} в накладе? В убытке. И... это бы убытки а вообще говоря {накладка|наклада} за то что наложено сверху это вообще это просто проценты {на} серебро конечно означает не серебро так напрасно сразу {думаете|думайте} о том что это такой металл который надо наложить куда-то конечно все это могло быть на далеко идет от значит серебро это деньги абсолютно точно то же самое как по французски {ажан} совершенно тот же семантический переход {а} накладное серебро это серебро {лихвенные|лихвинные} проценты то есть не присылаешь мне процентных денег и {не|рыб} кстати рыба {процент} в древнерусском {употреблении|потреблении} {почти заводит у и в} исчисляемом значении там одна рыба две рыбы {пятеро|пять} и так далее это и сейчас можно но у нас кроме того есть рыба как обобщающий рыбы как товар сколько рыбы мы можем сказать А древнерусский человек, он говорит, сколько рыб. Поэтому сейчас мы бы сказали, не присылаешь мне рыбы, как масло, как товара. {древнерусской|древнерусская} здесь не присылаешь не процентных денег не рыб То, что он должен был сделать. Дальше очень аккуратно он пишет следующее. Здесь смысловой разрыв, который я так символизирую, чтобы дальше мы будем читать. Ныне, в этом слове нормальный {ядь|ять} конечный, ныне с ядьем, так что ныне совершенно регулярно. Ныне не {пришлете|пришлите}... Да, мне не хватает строк. Ну, одну строку я еще умещу, но все равно это будет меньше, чем... Ну, ладно, одну строку ниже. Потому что я пишу строка в строку, чтобы у вас было представление о том, как выглядит письмо. Но все равно все письмо нельзя {уместить|вместить}. Или... А я напишу где-нибудь там рядом. А что такое к неделе? К воскресенью. Совершенно ясно. Эти два «и» – это двояти. К неделе. Соответственно, к неделе. Ну, а дальше все идет к описанному. {Причем|чем} он очень аккуратно, не ленится написать второй раз. И на этот раз уже с «ер»ом. Потому что это колеблется. Какое будет следующее слово на следующей строке? Рыб, конечно, {правят|правил}. Давайте тогда... Больше мне ничего не остается, как сюда перейти. Значит, следующая строка. Сколько у нас? Раз, два, три, четыре, пять. Шестая и последняя строка лицевой стороны – и это конец лицевой стороны. Так что дальше ему пришлось писать на... {наоборот истанины но|на оборотной сторонену} довольно понятно что {создать дом число и такое мнение|том присловий такой ныне} не... Не пришлете, а вот теперь, в том смысле, что я долго ждал, но уж теперь, если не пришлете, {подождите|подожнается}, к ближайшему воскресенью, процентных денег и рыб {замечательное|замечательно} не такое сейчас мы скажем {и нато до|иногда} {было|был} естественно не поскольку под отрицанием не рыб то что будет это и соответствует нашему то. Нормально в {XIV|четырнадцатом} веке оборот типа «если то» мог бы быть там какой-нибудь «оже и», в отличие от {XII|двенадцатого} века, где было не «и», а «а» в этом значении. Это меняется и тоже датирует довольно хорошо. И что такое, значит, «и {слатьми|слать ми} по вас»? Как вы это понимаете? Слать ми – это ровно тот же синтаксис, что у нас был мне выдавать мне свою дочь а сестре приставничать как Предстоит, должен и так далее. Совершенно точно. Провалиться мне сквозь землю. Вот типичный синтаксис, прекрасно работающий в современном русском языке. Ну или какой-нибудь там «мне скоро уезжать». Все эти формулы совершенно устойчивы. Так что «мне предстоит слать». Это говорится, очевидно, «{ну|но} ничего не поняла, и что, да? Мне ничего не {остаётся|остается}, как слать по вас». Ну и тогда попробуйте придумать как продолжение. слать по вас то есть за вами {придется|придет} мне придется {ссылать|слать} за вами и что то что начинается на {бит вирусчик|бибиющих} в этом что то есть да По сути дела, конечно, конечно, слать за вами каких-то, которые вам крепко покажут, как так плохо себя вести. Но это немножко надо... Может быть, от глагола «бить». Но не буду, действительно, {эта|это} задача немножко слишком сложная. Но следующая строка замечательным образом начинается с следующих четырех букв. Надо знать слово «беречь». Беречь в точности тот {человек}, гражданский офицер тот исполнитель судебный исполнитель которого который призначался для того чтобы там своими кулачными помощниками являлся {за} исканием долга наложением штрафа приведением человека в суд и так далее Так что {это|эта} угроза, которая у нас бывает в других формах и в других текстах, что если там что-то такое вовремя не будет возвращено или выплачено, то за это будет вызван этот... Беричи может иметь и другие названия. В самых древних текстах вместо слова «{беричь|беречь}» выступает слово «{отрак|отрок}» замечательным образом. Это вовсе не младенец, как раз очень такая фигура устрашающая. Это младший офицер, поэтому он отрок первоначально, но он отрок только по сравнению с «могучими воинами». а на самом деле облечен властью, и которого посылают для того, чтобы взыскать силы штраф и так далее. {Спереди|десперечи}. ну и последняя фраза понятно Это очень {такое|такая}... характерная фигура {такого действия|такая то действий}. Значит, если вы не выполните то, что я от вас хочу и требую, то я сделаю такую-то неприятную вещь, и уж тогда на меня не жалуйтесь. Здесь все очень понятно и прозрачно, кроме только одного места «ме», которое явно создает некоторую лингвистическую задачу. Потому что могло бы быть «мя», «намя», а «намя» се не {жальте|жаль}. И можно было бы даже думать о том, что здесь каким-то образом фонетическая смена на мне произошла. Почему и как, это был бы отдельный вопрос, но в принципе можно было думать. Если бы не то, что сочетания типа «намя», «замя», «натя», «затя», «предтя» в это время уже ушли из языка. И вместо них уже употребляются полные местоимения. На меня, за {тебя|себя}, за себя и так далее. Совершенно как у нас с вами. Это был бы большой анахронизм. Поэтому идти по пути и думать, каким образом здесь я изменилась на е, бессмысленно. Хронологически это {невозможно|нереально}. {Хронологически|хронлогически} единственное, что остается, это то, что очень простая вещь, что у него было что-то типа на {ме|мессе} {сине|не}, а должен было бы на ме не сине, с двумя не. И тогда {было|был} бы {ми не|дмене}, который полностью здесь ожидается по правилам {XIV|четырнадцатого} века. То есть из этих двух не, немножко разделенных между собой, он одно... по одной, ну, такой психологической ошибке, которая бывает, вообще говоря, пропустил. Так что нам приходится здесь признать все-таки некоторый маленький огрех. А смысл совершенно ясен. Ну вот, не хочу на этом слишком долго останавливаться. Тем не менее, вы видите, что... Вполне такое прозрачное письмо. очень характерного с характерной структурой и концом, который в разных вариантах у нас в других грамотах тоже встречается. Все, перехожу {к следующей Спасибо|в следующих рамтину у}. Вас тоже не удивит, если я скажу, что {если... Прямо|есть там}-то тоже {целые|целое}. Я бы сказал так, это меня самого удивляет. что такое возможно. Но тем не менее, это изобилие есть. Это просто следующие по порядку находки. находимся тоже поклон нет В данном случае он пишет... бытовым образом. {Так, ладно. А|поклоноа} это что за человек? Как вы его понимаете? Да, наверное, да. это вполне русское {прозвище|провозвище} действительно так совершенно верно уже эпохи поздней когда уже оглушение согласных может быть зафиксировано на письме Одно это было бы достаточно, чтобы никоим образом эта грамота не могла бы быть признана {XII|двенадцатого} века, сколько бы ни говорили, что вот мы нашли на такой-то глубине, ничего подобного. значит попал не туда потому что в мордке через {ты|т} может быть написано только начиная с конца {13|тринадцатого} века и позже ну и так далее это так {панике|паленький} пример такого датирования независимо от {стратеграфии|стратиграфии} {вот мордки вот мордки|от мортки от мортки} и дальше довольно любопытно получается две строки вместе очень Первый раз нам встречается, чтобы два человека таким образом обращались к кому бы то ни было. Один обращается к человеку, называя его Афанос, по именем, а другой обращается, называя его {господином|господину} моему. Можно вообще говоря представить себе, что они просто в разном положении находятся, что этот Семен, он же {Смён|смен}, {и} так сказать равный афаносу {а} {мордка|мортка} такой который себя равным {фанату|фаноса} считать не может возможно что решение в этом {и|мы} точно до конца {этого|это} не знаем сколько {до|то} единственный пример других у нас нет сравнить пока что не с чем и\n" + ] + } + ], + "source": [ + "audio_name = 'zaliznyak'\n", + "\n", + "base_pipeline_name = 'Pisets WhisperV3 no-VAD (segments 1s-20s) with scores'\n", + "additional_pipeline_name = 'W2V2 Golos LM'\n", + "\n", + "is_uncertain = uncertainty_results.query(\n", + " f'audio_name == \"{audio_name}\"'\n", + " f' and base_pipeline == \"{base_pipeline_name}\"'\n", + " ' and method == \"LM filtered diffs + WhisperLogProba_sum (t=-1)\"'\n", + ").iloc[0]['mask']\n", + "\n", + "truth_vs_base = results.query(\n", + " f'audio_name == \"{audio_name}\"'\n", + " f' and pipeline_name == \"{base_pipeline_name}\"'\n", + ").iloc[0]['alignment']\n", + "\n", + "truth_vs_additional = results.query(\n", + " f'audio_name == \"{audio_name}\"'\n", + " f' and pipeline_name == \"{additional_pipeline_name}\"'\n", + ").iloc[0]['alignment']\n", + "\n", + "base_vs_additional = MultipleTextsAlignment.from_strings(truth_vs_base.text2, truth_vs_additional.text2)\n", + "diffs_to_highlight = [i for i, m in enumerate(base_vs_additional.matches) if is_uncertain[m.start1:m.end1].any()]\n", + "print(base_vs_additional.substitute(show_in_braces=diffs_to_highlight))" + ] + }, { "cell_type": "code", "execution_count": null, From 33499b61528962fa800a76e23f55f7289930e223 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Sun, 1 Dec 2024 21:49:30 +0300 Subject: [PATCH 23/24] rm file --- evaluation/bond005_jsons_summarize.ipynb | 561 ----------------------- 1 file changed, 561 deletions(-) delete mode 100644 evaluation/bond005_jsons_summarize.ipynb diff --git a/evaluation/bond005_jsons_summarize.ipynb b/evaluation/bond005_jsons_summarize.ipynb deleted file mode 100644 index 6ecde4b..0000000 --- a/evaluation/bond005_jsons_summarize.ipynb +++ /dev/null @@ -1,561 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "from pathlib import Path\n", - "\n", - "import pandas as pd\n", - "\n", - "from asr.comparison import MultipleTextsAlignment" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
  bond005_wermy_wer
  whisperpodlodkadiffwhisperpodlodkadiff
audiosnr      
1_Зализняк_филологияnone9.712.5+2.99.812.5+2.7
01db50.736.4-14.241.834.3-7.5
02db9.111.7+2.79.311.5+2.2
03db9.120.1+11.09.313.1+3.8
04db9.512.2+2.79.712.2+2.5
05db9.311.9+2.69.511.9+2.4
2_Гарвард_философияnone2.02.7+0.72.02.7+0.7
01db2.43.1+0.72.43.1+0.7
02db3.44.4+1.03.44.4+1.0
03db2.23.7+1.52.23.7+1.5
04db2.73.6+0.92.73.6+0.9
05db2.83.3+0.52.63.3+0.7
3_Саватеев_математикаnone19.525.9+6.417.725.9+8.2
01db21.123.9+2.818.422.9+4.4
02db19.419.2-0.218.918.2-0.7
03db58.853.8-5.160.055.1-4.9
04db56.756.9+0.258.057.1-0.9
05db21.623.2+1.619.722.2+2.5
4_Жириновский_политикаnone6.88.6+1.76.88.6+1.7
01db33.331.1-2.233.431.1-2.3
02db14.78.3-6.410.38.3-2.0
03db14.98.3-6.510.58.3-2.1
04db17.518.7+1.217.518.7+1.2
05db14.38.0-6.39.98.0-1.9
5_Ланьков_историяnone8.610.3+1.68.610.3+1.7
01db13.011.4-1.613.111.4-1.8
02db30.533.7+3.230.333.9+3.7
03db15.028.8+13.815.021.4+6.4
04db10.311.2+1.010.311.3+1.0
05db9.910.1+0.210.010.1+0.2
\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# def get_longest_insertion(al: MultipleTextsAlignment) -> str:\n", - "# \"\"\"Get character length if the insertion with max words\n", - "# TODO fix, need to search in .is_replace ops also\n", - "# \"\"\"\n", - "# insertions = [m for m in al.matches if m.is_insert]\n", - "# if len(insertions) == 0:\n", - "# return ''\n", - "# max_insertion = max(insertions, key=lambda m: m.len2)\n", - "# inserted_words = al.text2.get_words()[max_insertion.start2:max_insertion.end2]\n", - "# return al.text2.text[inserted_words[0].start:inserted_words[-1].stop]\n", - "\n", - "def display_results(results: pd.DataFrame):\n", - " display(\n", - " results.style.format({\n", - " ('bond005_wer', 'whisper'): '{:.1f}',\n", - " ('bond005_wer', 'podlodka'): '{:.1f}',\n", - " ('bond005_wer', 'diff'): '{:+.1f}',\n", - " ('my_wer', 'whisper'): '{:.1f}',\n", - " ('my_wer', 'podlodka'): '{:.1f}',\n", - " ('my_wer', 'diff'): '{:+.1f}',\n", - " }).set_table_styles([\n", - " {\"selector\": \"td, th\", \"props\": [(\"border\", \"1px solid grey !important\")]},\n", - " ])\n", - " )\n", - "\n", - "base_dir = Path('../long_audio_ru')\n", - "\n", - "results = []\n", - "\n", - "names = ['1_Зализняк_филология', '2_Гарвард_философия', '3_Саватеев_математика', '4_Жириновский_политика', '5_Ланьков_история']\n", - "\n", - "for i in range(1, 6):\n", - " for snr in ['none', '01db', '02db', '03db', '04db', '05db']:\n", - "\n", - " # reading reports\n", - " dir = base_dir if snr == 'none' else base_dir / f'augmented/{snr}'\n", - " with open(f'{dir}/report_for_vad_pipeline_{i}.json') as f:\n", - " podlodka_preds_json = json.load(f)\n", - " with open(f'{dir}/report_for_vad_pipeline_{i}_multi.json') as f:\n", - " whisper_preds_json = json.load(f)\n", - "\n", - " # true transcription\n", - " truth = whisper_preds_json['true']\n", - " assert podlodka_preds_json['true'] == whisper_preds_json['true']\n", - "\n", - " # alignments\n", - " al_whisper = MultipleTextsAlignment.from_strings(truth, whisper_preds_json['pred'])\n", - " al_podlodka = MultipleTextsAlignment.from_strings(truth, podlodka_preds_json['pred'])\n", - " \n", - " # results\n", - " results.append({\n", - " 'audio': names[i - 1],\n", - " 'snr': snr,\n", - " ('bond005_wer', 'whisper'): 100 * float(whisper_preds_json['WER'][:-1]),\n", - " ('bond005_wer', 'podlodka'): 100 * float(podlodka_preds_json['WER'][:-1]),\n", - " ('my_wer', 'whisper'): 100 * al_whisper.wer()['wer'],\n", - " ('my_wer', 'podlodka'): 100 * al_podlodka.wer()['wer'],\n", - " # ('longest_insertion_len', 'whisper'): len(get_longest_insertion(al_whisper)),\n", - " # ('longest_insertion_len', 'podlodka'): len(get_longest_insertion(al_podlodka)),\n", - " })\n", - "\n", - "results = pd.DataFrame(results).set_index(['audio', 'snr'])\n", - "results.columns = pd.MultiIndex.from_tuples(results.columns)\n", - "results.index = pd.MultiIndex.from_tuples(results.index, names=['audio', 'snr'])\n", - "\n", - "results.insert(\n", - " loc=results.columns.get_loc(('bond005_wer', 'podlodka')) + 1,\n", - " column=('bond005_wer', 'diff'),\n", - " value=results[('bond005_wer', 'podlodka')] - results[('bond005_wer', 'whisper')],\n", - ")\n", - "results.insert(\n", - " loc=results.columns.get_loc(('my_wer', 'podlodka')) + 1,\n", - " column=('my_wer', 'diff'),\n", - " value=results[('my_wer', 'podlodka')] - results[('my_wer', 'whisper')],\n", - ")\n", - "\n", - "display_results(results)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 bond005_wermy_wer
 whisperpodlodkadiffwhisperpodlodkadiff
audio      
1_Зализняк_филология16.217.5+1.314.915.9+1.0
2_Гарвард_философия2.63.5+0.92.53.5+0.9
3_Саватеев_математика32.933.8+1.032.133.5+1.4
4_Жириновский_политика16.913.8-3.114.713.8-0.9
5_Ланьков_история14.617.6+3.014.616.4+1.9
\n" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "display_results(results.groupby('audio').mean())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "venv", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} From c6271f6f90450a7966acb8f9bfe4317aaf8377e2 Mon Sep 17 00:00:00 2001 From: Oleg Sedukhin Date: Tue, 3 Dec 2024 20:48:28 +0300 Subject: [PATCH 24/24] fix import; fix non-determinism --- asr/lm.py | 1 - evaluation/make_predictions.py | 4 +++- ...with_whisper_scores.py => make_predictions_with_scores.py} | 0 3 files changed, 3 insertions(+), 2 deletions(-) rename evaluation/{make_predictions_with_whisper_scores.py => make_predictions_with_scores.py} (100%) diff --git a/asr/lm.py b/asr/lm.py index cd15983..f1b92df 100644 --- a/asr/lm.py +++ b/asr/lm.py @@ -64,7 +64,6 @@ def get_all_subsets(elements: list[Any]): for r in range(len(elements) + 1) ), []) -scorer = SequenceScore('ai-forever/rugpt3large_based_on_gpt2') def accept_suggestions_by_lm( base_vs_additional: MultipleTextsAlignment, diff --git a/evaluation/make_predictions.py b/evaluation/make_predictions.py index 6106615..9205f64 100644 --- a/evaluation/make_predictions.py +++ b/evaluation/make_predictions.py @@ -67,7 +67,9 @@ def __call__(self, waveform: np.ndarray) -> dict[str, str]: result = self.whisper_pipeline.model.generate( **inputs.to('cuda'), condition_on_prev_tokens=self.condition_on_prev_tokens, - temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0), + # temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0), + temperature=0, # for determinism + do_sample=False, # for determinism logprob_threshold=-1.0, compression_ratio_threshold=1.35, return_timestamps=True, diff --git a/evaluation/make_predictions_with_whisper_scores.py b/evaluation/make_predictions_with_scores.py similarity index 100% rename from evaluation/make_predictions_with_whisper_scores.py rename to evaluation/make_predictions_with_scores.py