yum-slop/TaSTT

Free self-hosted STT for VRChat.

git clone https://git.yummers.dev/yum-slop/TaSTT

yumImport FastTextPager repof6b93a2

master
21.8 KiB525 linesraw
1# !!! AI ARTIFACT !!!
2# This file was primarily written with AI.
3
4import os
5import argparse
6from datasets import load_dataset, interleave_datasets
7import sentencepiece as spm
8import itertools
9import random
10from unidecode import unidecode
11
12# --- Dataset 1: Wikipedia ---
13DATASET_NAME_WIKI = "wikipedia"
14DATASET_CONFIG_WIKI = "20220301.en"
15
16DATASET_SPLIT_WIKI = "train"
17TEXT_COLUMN_WIKI = "text"
18
19# --- Dataset 2: DailyDialog ---
20DATASET_NAME_DD = "daily_dialog"
21DATASET_SPLIT_DD = "train"
22UTTERANCE_COLUMN_DD = "dialog"
23
24# --- Dataset 3: BlendedSkillTalk (includes Persona-Chat) ---
25DATASET_NAME_BST = "blended_skill_talk"
26DATASET_SPLIT_BST = "train"
27
28# --- Dataset 4: OpenSubtitles ---
29DATASET_NAME_OS = "Helsinki-NLP/open_subtitles"
30DATASET_LANG_PAIR_OS = ("en", "fr") # Load en-fr and use the 'en' part
31DATASET_SPLIT_OS = "train"
32TEXT_COLUMN_OS = "en" # Access via item['translation']['en']
33
34# Reserve one space for special "empty" token.
35VOCAB_SIZE = 65535
36OUTPUT_DIR = "./custom_unigram_tokenizer_65k"
37MODEL_PREFIX = os.path.join(OUTPUT_DIR, "unigram")
38MODEL_FILE = MODEL_PREFIX + ".model"
39VOCAB_FILE = MODEL_PREFIX + ".vocab"
40
41UNK_TOKEN = "[UNK]"
42PAD_TOKEN = "[PAD]"
43CONTROL_SYMBOLS = ["[CLS]", "[SEP]", "[MASK]"]
44
45BATCH_SIZE = 1000
46
47def wiki_iterator(dataset_wiki, batch_size=BATCH_SIZE):
48    if dataset_wiki:
49        for i in range(0, len(dataset_wiki), batch_size):
50            yield [unidecode(text) for text in dataset_wiki[i : i + batch_size][TEXT_COLUMN_WIKI] if text]
51
52def dd_iterator(dataset_dd, batch_size=BATCH_SIZE):
53    if dataset_dd:
54        current_batch = []
55        for dialogue in dataset_dd:
56            utterances = dialogue[UTTERANCE_COLUMN_DD]
57            for utterance in utterances:
58                if utterance:
59                    normalized_utterance = unidecode(utterance)
60                    current_batch.append(normalized_utterance)
61                    if len(current_batch) == batch_size:
62                        yield current_batch
63                        current_batch = []
64        if current_batch:
65            yield current_batch
66
67def bst_iterator(dataset_bst, batch_size=BATCH_SIZE):
68    if dataset_bst:
69        current_batch = []
70        for session in dataset_bst:
71            texts_to_add = []
72            if session.get("previous_utterance"):
73                texts_to_add.append(session["previous_utterance"])
74            if session.get("free_messages"):
75                texts_to_add.extend(session["free_messages"])
76            if session.get("guided_messages"):
77                texts_to_add.extend(session["guided_messages"])
78
79            for text in texts_to_add:
80                if text and isinstance(text, str):
81                    normalized_text = unidecode(text)
82                    current_batch.append(normalized_text)
83                    if len(current_batch) == batch_size:
84                        yield current_batch
85                        current_batch = []
86        if current_batch:
87            yield current_batch
88
89def os_iterator(dataset_os, batch_size=BATCH_SIZE, lang_code=TEXT_COLUMN_OS):
90    if dataset_os:
91        current_batch = []
92        for item in dataset_os:
93            text = item['translation'][lang_code]
94            if text:
95                normalized_text = unidecode(text)
96                current_batch.append(normalized_text)
97                if len(current_batch) == batch_size:
98                    yield current_batch
99                    current_batch = []
100        if current_batch:
101            yield current_batch
102
103def count_wiki_items_chars(dataset_wiki):
104    item_count = 0
105    char_count = 0
106    if dataset_wiki:
107        item_count = len(dataset_wiki)
108        try:
109            char_count = sum(len(unidecode(text)) for text in dataset_wiki[TEXT_COLUMN_WIKI] if text and isinstance(text, str))
110        except Exception:
111            print(f"Warning: Direct column access for char count failed for {DATASET_NAME_WIKI}. Iterating row by row (slower).")
112            char_count = sum(len(unidecode(row[TEXT_COLUMN_WIKI])) for row in dataset_wiki if row.get(TEXT_COLUMN_WIKI) and isinstance(row[TEXT_COLUMN_WIKI], str))
113
114    return item_count, char_count
115
116def count_dd_items_chars(dataset_dd):
117    item_count = 0
118    char_count = 0
119    if dataset_dd:
120        for dialogue in dataset_dd:
121            utterances = dialogue[UTTERANCE_COLUMN_DD]
122            for utterance in utterances:
123                if utterance and isinstance(utterance, str):
124                    item_count += 1
125                    char_count += len(unidecode(utterance))
126    return item_count, char_count
127
128def count_bst_items_chars(dataset_bst):
129    item_count = 0
130    char_count = 0
131    if dataset_bst:
132        for session in dataset_bst:
133            texts_to_process = []
134            # Gather all potential text strings first
135            prev_utt = session.get("previous_utterance")
136            if prev_utt and isinstance(prev_utt, list):
137                 if len(prev_utt) > 0 and isinstance(prev_utt[0], str):
138                      texts_to_process.append(prev_utt[0])
139            elif prev_utt and isinstance(prev_utt, str):
140                texts_to_process.append(prev_utt)
141
142            free_msgs = session.get("free_messages")
143            if free_msgs and isinstance(free_msgs, list):
144                 for item in free_msgs:
145                     if isinstance(item, list):
146                         texts_to_process.extend(msg for msg in item if msg and isinstance(msg, str))
147                     elif isinstance(item, str):
148                          texts_to_process.append(item)
149
150            guided_msgs = session.get("guided_messages")
151            if guided_msgs and isinstance(guided_msgs, list):
152                 for item in guided_msgs:
153                     if isinstance(item, list):
154                         texts_to_process.extend(msg for msg in item if msg and isinstance(msg, str))
155                     elif isinstance(item, str):
156                          texts_to_process.append(item)
157
158            # Count items and chars from the gathered list
159            for text in texts_to_process:
160                 if text and isinstance(text, str):
161                     normalized_text = unidecode(text)
162                     if normalized_text:
163                         item_count += 1
164                         char_count += len(normalized_text)
165
166    return item_count, char_count
167
168def count_os_items_chars(dataset_os, lang_code=TEXT_COLUMN_OS):
169    item_count = 0
170    char_count = 0
171    if dataset_os:
172        for item in dataset_os:
173            text = item['translation'][lang_code]
174            if text and isinstance(text, str):
175                normalized_text = unidecode(text)
176                if normalized_text: # Ensure not empty after unidecode
177                    item_count += 1
178                    char_count += len(normalized_text)
179    return item_count, char_count
180
181def load_and_count_datasets(wiki_fraction, subtitles_fraction):
182    """Loads, potentially shrinks, and counts items/chars in datasets."""
183    datasets = {}
184    counts = {}
185    total_items = 0
186    total_chars = 0
187
188    # --- Wikipedia ---
189    print(f"Loading dataset 1: {DATASET_NAME_WIKI} ({DATASET_CONFIG_WIKI}), split: {DATASET_SPLIT_WIKI}")
190    dataset_wiki_full = load_dataset(DATASET_NAME_WIKI, DATASET_CONFIG_WIKI, split=DATASET_SPLIT_WIKI, trust_remote_code=True)
191    print(f"Original Wikipedia dataset size: {len(dataset_wiki_full):,}")
192
193    # Shrink the Wikipedia dataset
194    split_test_size = 1.0 - wiki_fraction
195    shrunk_dataset_split = dataset_wiki_full.train_test_split(test_size=split_test_size, seed=random.randint(0, 1000000))
196    dataset_wiki = shrunk_dataset_split['train']
197    print(f"Using {wiki_fraction*100:.3f}% of Wikipedia dataset: {len(dataset_wiki):,} items")
198
199    count_wiki, chars_wiki = count_wiki_items_chars(dataset_wiki)
200    print(f"Wikipedia dataset loaded (shrunk). Precise text items: {count_wiki:,}, Characters: {chars_wiki:,}")
201    datasets['wiki'] = dataset_wiki
202    counts['wiki'] = (count_wiki, chars_wiki)
203    total_items += count_wiki
204    total_chars += chars_wiki
205
206    # --- DailyDialog ---
207    print(f"\nLoading dataset 2: {DATASET_NAME_DD}, split: {DATASET_SPLIT_DD}")
208    dataset_dd = load_dataset(DATASET_NAME_DD, split=DATASET_SPLIT_DD, trust_remote_code=True)
209    count_dd, chars_dd = count_dd_items_chars(dataset_dd)
210    print(f"DailyDialog dataset loaded. Precise text items (utterances): {count_dd:,}, Characters: {chars_dd:,}")
211    datasets['dd'] = dataset_dd
212    counts['dd'] = (count_dd, chars_dd)
213    total_items += count_dd
214    total_chars += chars_dd
215
216    # --- BlendedSkillTalk ---
217    print(f"\nLoading dataset 3: {DATASET_NAME_BST}, split: {DATASET_SPLIT_BST}")
218    dataset_bst = load_dataset(DATASET_NAME_BST, split=DATASET_SPLIT_BST, trust_remote_code=True)
219    count_bst, chars_bst = count_bst_items_chars(dataset_bst)
220    print(f"BlendedSkillTalk dataset loaded. Precise text items (extracted): {count_bst:,}, Characters: {chars_bst:,}")
221    datasets['bst'] = dataset_bst
222    counts['bst'] = (count_bst, chars_bst)
223    total_items += count_bst
224    total_chars += chars_bst
225
226    # --- OpenSubtitles ---
227    print(f"\nLoading dataset 4: {DATASET_NAME_OS}, lang_pair: {DATASET_LANG_PAIR_OS}, split: {DATASET_SPLIT_OS}")
228    # Note: OpenSubtitles can be very large. Consider streaming or specific configurations if memory is an issue.
229    # For now, loading a standard configuration.
230    dataset_os = load_dataset(DATASET_NAME_OS, lang1=DATASET_LANG_PAIR_OS[0], lang2=DATASET_LANG_PAIR_OS[1], split=DATASET_SPLIT_OS, trust_remote_code=True)
231    split_test_size = 1.0 - subtitles_fraction
232    dataset_os = dataset_os.train_test_split(test_size=split_test_size, seed=random.randint(0, 1000000))['train']
233    count_os, chars_os = count_os_items_chars(dataset_os, lang_code=TEXT_COLUMN_OS)
234    print(f"OpenSubtitles dataset loaded. Precise text items: {count_os:,}, Characters: {chars_os:,}")
235    datasets['os'] = dataset_os
236    counts['os'] = (count_os, chars_os)
237    total_items += count_os
238    total_chars += chars_os
239
240    print(f"\nTotal precise text items from loaded datasets: {total_items:,}")
241    print(f"Total characters from loaded datasets: {total_chars:,}")
242
243    return datasets, counts
244
245def train_tokenizer(model_prefix, datasets, counts):
246    """Trains a Unigram tokenizer using SentencePiece and saves it."""
247    total_items = sum(c[0] for c in counts.values())
248    total_chars = sum(c[1] for c in counts.values())
249    if total_items == 0:
250        print("Error: No items found in the datasets to train on. Exiting training.")
251        return
252
253    print(f"\nTotal precise text items for training: {total_items:,}")
254    print(f"Total characters for training: {total_chars:,}")
255
256    output_dir = os.path.dirname(model_prefix)
257    os.makedirs(output_dir, exist_ok=True)
258
259    print(f"\nStarting SentencePiece Unigram tokenizer training with vocab size: {VOCAB_SIZE}")
260    print(f"Using combined dataset iterator.")
261    print(f"Output model prefix: {model_prefix}")
262
263    iterators_to_chain = []
264    def flatten_iterator(iterator):
265        for batch in iterator:
266            for item in batch:
267                yield item
268
269    if datasets.get('wiki') and counts['wiki'][0] > 0:
270        iterators_to_chain.append(flatten_iterator(wiki_iterator(datasets['wiki'])))
271    if datasets.get('dd') and counts['dd'][0] > 0:
272        iterators_to_chain.append(flatten_iterator(dd_iterator(datasets['dd'])))
273    if datasets.get('bst') and counts['bst'][0] > 0:
274        iterators_to_chain.append(flatten_iterator(bst_iterator(datasets['bst'])))
275    if datasets.get('os') and counts['os'][0] > 0:
276        iterators_to_chain.append(flatten_iterator(os_iterator(datasets['os'])))
277
278    if not iterators_to_chain:
279        print("Error: No valid dataset iterators available for training. Exiting.")
280        return
281
282    combined_iterator = itertools.chain(*iterators_to_chain)
283
284    # Include whitespace symbols so we can efficiently break lines.
285    # If we include the single space, it prevents the tokenizer from merging
286    # spaces with regular words, and tanks the average chars/token.
287    # This many tokens is kinda overkill, but it gives us a way to efficiently
288    # clear even fairly large boards, so I think it's worth.
289    whitespace_symbols = []
290    for i in range(2, 40):
291        whitespace_symbols.append('▁' * i)
292
293    spm.SentencePieceTrainer.train(
294        sentence_iterator=combined_iterator,
295        model_prefix=model_prefix,
296        vocab_size=VOCAB_SIZE,
297        model_type='unigram',
298        character_coverage=1.0,
299        unk_piece=UNK_TOKEN,
300        pad_piece=PAD_TOKEN,
301        control_symbols=CONTROL_SYMBOLS,
302        user_defined_symbols=whitespace_symbols,
303        # These whitespace options must be false, or else whitespace won't
304        # be respected when encoding.
305        add_dummy_prefix=False,
306        remove_extra_whitespaces=False,
307        split_by_whitespace=False,
308        num_threads=os.cpu_count(),
309        input_sentence_size=total_items,
310    )
311    print("\nTraining finished.")
312    print(f"SentencePiece model saved to: {model_prefix}.model")
313    print(f"SentencePiece vocabulary saved to: {model_prefix}.vocab")
314
315def extract_text_samples(dataset, count, num_samples, text_extractor_func):
316    """Extracts a specified number of text samples from a dataset."""
317    samples = []
318    if dataset is None or count == 0 or num_samples == 0:
319        return samples
320    # Take samples from the beginning, ensure we don't exceed dataset size
321    actual_samples_to_take = min(num_samples, count)
322    # Use the provided function to extract text correctly for this dataset type
323    samples = text_extractor_func(dataset.select(range(actual_samples_to_take)))
324    return [unidecode(s) for s in samples if s and isinstance(s,str)]
325
326def wiki_text_extractor(dataset_slice):
327    """Extracts text from a slice of the Wikipedia dataset."""
328    return [text for text in dataset_slice[TEXT_COLUMN_WIKI] if text]
329
330def dd_text_extractor(dataset_slice):
331    """Extracts text from a slice of the DailyDialog dataset."""
332    texts = []
333    for dialogue in dataset_slice:
334        texts.extend(utterance for utterance in dialogue[UTTERANCE_COLUMN_DD] if utterance)
335    return texts
336
337def bst_text_extractor(dataset_slice):
338    """Extracts text from a slice of the BlendedSkillTalk dataset."""
339    texts = []
340    for session in dataset_slice:
341        if session.get("previous_utterance"):
342            texts.append(session["previous_utterance"])
343        if session.get("free_messages"):
344            texts.extend(session["free_messages"])
345        if session.get("guided_messages"):
346            texts.extend(session["guided_messages"])
347    return [t for t in texts if t and isinstance(t,str)] # Ensure only valid strings
348
349def os_text_extractor(dataset_slice, lang_code=TEXT_COLUMN_OS):
350    """Extracts text from a slice of the OpenSubtitles dataset."""
351    texts = []
352    for item in dataset_slice:
353        text = item['translation'][lang_code]
354        if text and isinstance(text, str):
355            texts.append(text)
356    return texts
357
358def test_tokenizer(model_path, datasets, counts, sample_size=1000):
359    print("\n--- Testing the trained Unigram (SentencePiece) tokenizer on data sample ---")
360    if sample_size <= 0:
361        print("Error: Sample size for testing must be positive.")
362        return
363
364    sp = spm.SentencePieceProcessor()
365    sp.load(model_path)
366    print(f"Successfully loaded SentencePiece model from: {model_path}")
367    print(f"Vocabulary size: {sp.get_piece_size()}")
368
369    # Identify available datasets with content
370    available_datasets = {
371        name: data
372        for name, data in datasets.items()
373        if counts[name][0] > 0
374    }
375    num_available_datasets = len(available_datasets)
376
377    if num_available_datasets == 0:
378        print("Warning: No data available in loaded datasets to sample for testing.")
379        return
380
381    print(f"Found {num_available_datasets} non-empty dataset(s) for testing.")
382    print(f"Attempting to sample up to {sample_size} total items equally from these datasets...")
383
384    # Calculate equal number of samples per available dataset
385    samples_per_dataset = sample_size // num_available_datasets
386    print(f"Targeting approximately {samples_per_dataset} samples per dataset.")
387
388    test_samples = []
389    dataset_extractors = {
390        'wiki': wiki_text_extractor,
391        'dd': dd_text_extractor,
392        'bst': bst_text_extractor,
393        'os': os_text_extractor,
394    }
395
396    actual_samples_collected = {}
397
398    # Sample equally from each available dataset
399    for name, dataset in available_datasets.items():
400        count = counts[name][0]
401        extractor = dataset_extractors[name]
402
403        # Determine the target number of samples for *this* dataset
404        num_samples_to_target = min(samples_per_dataset, count)
405        if num_samples_to_target <= 0:
406            print(f"  Skipping '{name}' (no items requested or available).")
407            actual_samples_collected[name] = 0
408            continue
409
410        print(f"  Sampling from '{name}' (target: {num_samples_to_target} items)...")
411
412        items_before = len(test_samples)
413        # For OpenSubtitles, pass the lang_code if the extractor needs it
414        if name == 'os':
415            extracted_items = extract_text_samples(dataset, count, num_samples_to_target, lambda ds_slice: extractor(ds_slice, lang_code=TEXT_COLUMN_OS))
416        else:
417            extracted_items = extract_text_samples(dataset, count, num_samples_to_target, extractor)
418
419        # Limit the extracted items to the target number
420        final_samples_for_dataset = extracted_items[:num_samples_to_target]
421
422        test_samples.extend(final_samples_for_dataset)
423        items_added = len(final_samples_for_dataset)
424        actual_samples_collected[name] = items_added
425        print(f"    Added {items_added} items from '{name}'.")
426
427    actual_sample_size = len(test_samples)
428    if actual_sample_size == 0:
429        print("\nCould not gather any samples for testing.")
430        print("--- Test finished ---")
431        return
432
433    print(f"\n--- Starting Test Run ---")
434    print(f"Testing on {actual_sample_size} sampled text items (final count).")
435    print(f"Samples breakdown: {actual_samples_collected}")
436
437    total_chars = 0
438    total_tokens = 0
439    examples_to_show = 5
440    examples_shown = 0
441
442    random.shuffle(test_samples)
443
444    for i, text_sample in enumerate(test_samples):
445        if not text_sample: # Should already be filtered by extractor, but double-check
446            continue
447        try:
448            tokens = sp.encode_as_pieces(text_sample)
449            num_tokens = len(tokens)
450            num_chars = len(text_sample)
451            total_tokens += num_tokens
452            total_chars += num_chars
453
454            if examples_shown < examples_to_show:
455                print(f"\nSample {examples_shown + 1} (Overall index {i}):") # Clarify sample index
456                print(f"  Text ({num_chars} chars): {text_sample[:100]}{'...' if len(text_sample)>100 else ''}")
457                print(f"  Tokens ({num_tokens}): {tokens}")
458                examples_shown += 1
459        except Exception as e:
460            print(f"\nWarning: Error encoding sample (Overall index {i}) with SentencePiece. Skipping sample. Error: {e}")
461            print(f"Problematic sample text: {text_sample[:100]}...") # Show problematic text
462
463    # --- Test Summary ---
464    if total_tokens > 0 and total_chars > 0 : # Avoid division by zero
465        avg_chars_per_token = total_chars / total_tokens
466        print(f"\n--- Test Summary ---")
467        print(f"Tested on {actual_sample_size} text items.")
468        print(f"Final samples breakdown: {actual_samples_collected}")
469        print(f"Total characters in final sample: {total_chars:,}")
470        print(f"Total tokens in final sample: {total_tokens:,}")
471        print(f"Average characters per token: {avg_chars_per_token:.4f}")
472    elif actual_sample_size > 0:
473         print("\n--- Test Summary ---")
474         print(f"Tested on {actual_sample_size} text items.")
475         print(f"Final samples breakdown: {actual_samples_collected}")
476         print("No valid tokens were generated from the sample data (or samples were empty after unidecode).")
477    else:
478        # This case should be caught earlier, but included for completeness
479        print("\n--- Test Summary ---")
480        print("No samples were collected or processed.")
481
482    print("--- Test finished ---")
483
484def parse_args():
485    parser = argparse.ArgumentParser(description="Train and test a Unigram tokenizer on combined datasets.")
486    parser.add_argument("--train", action="store_true", help="Train the tokenizer on datasets")
487    parser.add_argument("--test", action="store_true", help="Test the trained tokenizer")
488    parser.add_argument("--sample_size", type=int, default=1000, help="Number of items to sample for testing")
489    parser.add_argument("--wiki_fraction", type=float, default=0.05, help="Fraction of Wikipedia dataset to use (e.g., 0.05 for 5%)")
490    parser.add_argument("--subtitles_fraction", type=float,
491                        default=0.05, help="Fraction of opensubtitles dataset to use (e.g., 0.05 for 5%)")
492    return parser.parse_args()
493
494if __name__ == "__main__":
495    args = parse_args()
496
497    # Load datasets and calculate counts once
498    print("--- Loading and Counting Datasets ---")
499    datasets, counts = load_and_count_datasets(wiki_fraction=args.wiki_fraction, subtitles_fraction=args.subtitles_fraction)
500    print("--- Dataset Loading Finished ---")
501
502    run_train = args.train
503    run_test = args.test
504
505    # If no arguments provided, run both by default
506    if not args.train and not args.test:
507        run_train = True
508        run_test = True
509
510    if run_train:
511        print("\n--- Starting Tokenizer Training ---")
512        train_tokenizer(MODEL_PREFIX, datasets, counts)
513        print("--- Training Finished ---")
514
515    if run_test:
516        # Ensure tokenizer file exists if training didn't just run
517        if not os.path.exists(MODEL_FILE):
518            print(f"\nError: SentencePiece model file {MODEL_FILE} not found. Cannot run test.")
519            print("Please run with --train first or ensure the file exists.")
520        else:
521            print("\n--- Starting Tokenizer Testing ---")
522            test_tokenizer(MODEL_FILE, datasets, counts, sample_size=args.sample_size)
523            print("--- Testing Finished ---")
524
525    print("\nScript finished.")