Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python3 | |
| """ | |
| Inference script for MHG text normalization. | |
| Apply trained model to new, unannotated MHG texts. | |
| Supports sliding-window stride inference for long texts that exceed | |
| the model's maximum sequence length. When stride is enabled, long | |
| inputs are split into overlapping windows, each normalized independently, | |
| and predictions are merged preferring window centers (where context is | |
| richest). | |
| """ | |
| import os | |
| import yaml | |
| import torch | |
| import argparse | |
| from pathlib import Path | |
| from typing import List, Optional, Tuple | |
| from transformers import AutoTokenizer, AutoModelForSeq2SeqLM | |
| import re | |
| import difflib | |
| from tqdm import tqdm | |
| # Editorial punctuation characters to strip before normalization and restore after. | |
| # These appear in edited/printed text but were NOT present in the training data. | |
| # MHG abbreviation markers (hyphens '-', apostrophes '\u2019') are NOT included | |
| # here because they are meaningful for the normalization task. | |
| _PUNCT_CHARS = frozenset(".,;:!?()[]«»‹›\u201c\u201d\u201e\"\u201a\u2018\u2019\u2014\u2013<>") | |
| def _attach_en_proclitic(text: str) -> str: | |
| """Attach free-standing negation proclitic 'en' to the following word. | |
| In Middle High German, the negation proclitic 'en' appears as a separate | |
| token before the word it negates. This post-processing step joins them | |
| directly without a separator, e.g. 'en guot' → 'enguot'. | |
| Capitalization of the proclitic is preserved: 'En guot' → 'Enguot'. | |
| Only attaches when 'en' appears as a standalone, case-insensitive word | |
| (bounded by whitespace or start/end of string) and is followed by another | |
| word. Punctuation after 'en' prevents attachment. | |
| Args: | |
| text: Input text to process | |
| Returns: | |
| Text with 'en' proclitics attached to the following word | |
| """ | |
| # Match standalone "en" (case-insensitive) as a whole word, | |
| # followed by whitespace and a non-whitespace character. | |
| pattern = re.compile(r'(?<!\S)(en)(?!\S)\s+(\S)', re.IGNORECASE) | |
| def _replace(m): | |
| en_word = m.group(1) | |
| next_char = m.group(2) | |
| return f"{en_word}{next_char}" | |
| return pattern.sub(_replace, text) | |
| def _lenition_t_after_n_before_vowel(text: str) -> str: | |
| """MHG Lenition: /t/ → /d/ after /n/ and before a vowel. | |
| In Middle High German, the voiceless stop /t/ becomes voiced /d/ when | |
| it appears between /n/ and a vowel (within the same word). This covers | |
| all MHG vowels including those with diacritics (ä, ö, ü, ê, î, etc.) | |
| and base vowels followed by combining diacritical marks. | |
| Capitalization is preserved: 'Ntô' → 'Ndô', 'nTô' → 'ndô'. | |
| Args: | |
| text: Input text to process | |
| Returns: | |
| Text with lenited t → d where applicable | |
| """ | |
| # Vowel set: MHG base vowels plus all common diacritic variants | |
| vowel_chars = 'aeiouyAEIOUY' | |
| vowel_chars += 'äöüÄÖÜ' # umlaut | |
| vowel_chars += 'âêîôûÂÊÎÔÛ' # circumflex | |
| vowel_chars += 'āēīōūĀĒĪŌŪ' # macron | |
| vowel_chars += 'åæœøÅÆŒØ' # other | |
| # Match 'n' + 't' followed by a vowel (optionally with combining diacritics) | |
| pattern = re.compile( | |
| r'([nN])([tT])(?=[' + re.escape(vowel_chars) + r'][\u0300-\u036f]*)' | |
| ) | |
| def _replace(m): | |
| n_char = m.group(1) | |
| t_char = m.group(2) | |
| return n_char + ('d' if t_char == 't' else 'D') | |
| return pattern.sub(_replace, text) | |
| def _lenition_t_after_l_before_vowel(text: str) -> str: | |
| """MHG Lenition: /t/ → /d/ after /l/ and before a vowel. | |
| In Middle High German, the voiceless stop /t/ becomes voiced /d/ when | |
| it appears between /l/ and a vowel (within the same word). This covers | |
| all MHG vowels including those with diacritics (ä, ö, ü, ê, î, etc.) | |
| and base vowels followed by combining diacritical marks. | |
| Capitalization is preserved: 'Ltô' → 'Ldô', 'lTô' → 'ldô'. | |
| Args: | |
| text: Input text to process | |
| Returns: | |
| Text with lenited t → d where applicable | |
| """ | |
| # Vowel set: MHG base vowels plus all common diacritic variants | |
| vowel_chars = 'aeiouyAEIOUY' | |
| vowel_chars += 'äöüÄÖÜ' # umlaut | |
| vowel_chars += 'âêîôûÂÊÎÔÛ' # circumflex | |
| vowel_chars += 'āēīōūĀĒĪŌŪ' # macron | |
| vowel_chars += 'åæœøÅÆŒØ' # other | |
| # Match 'l' + 't' followed by a vowel (optionally with combining diacritics) | |
| pattern = re.compile( | |
| r'([lL])([tT])(?=[' + re.escape(vowel_chars) + r'][\u0300-\u036f]*)' | |
| ) | |
| def _replace(m): | |
| l_char = m.group(1) | |
| t_char = m.group(2) | |
| return l_char + ('d' if t_char == 't' else 'D') | |
| return pattern.sub(_replace, text) | |
| def _niet_to_niht(text: str) -> str: | |
| """Change freestanding 'niet'/'niut' to 'niht'. | |
| In some MHG texts, the negation word 'niet' (or variant 'niut') appears | |
| as a freestanding token but the preferred normalized form is 'niht'. | |
| This post-processing step replaces standalone occurrences of 'niet' and | |
| 'niut' (case-insensitive) with 'niht', preserving the original | |
| capitalization pattern. | |
| Handles words immediately followed by editorial punctuation (from | |
| _PUNCT_CHARS) by capturing and re-attaching the trailing punctuation. | |
| Examples: | |
| 'niet' → 'niht' | |
| 'niut' → 'niht' | |
| 'Niet' → 'Niht' | |
| 'Niut' → 'Niht' | |
| 'NIET' → 'NIHT' | |
| 'NIUT' → 'NIHT' | |
| 'niet.' → 'niht.' | |
| 'niet,' → 'niht,' | |
| 'niet.)' → 'niht.)' | |
| Args: | |
| text: Input text to process | |
| Returns: | |
| Text with freestanding 'niet'/'niut' changed to 'niht' | |
| """ | |
| # Build character class of all editorial punctuation characters so we can | |
| # explicitly match trailing punctuation and re-attach it after replacement. | |
| # This is necessary because post-processing runs AFTER punctuation restoration, | |
| # so words like "niet." have punctuation attached. | |
| punct_class = re.escape(''.join(_PUNCT_CHARS)) | |
| # Match standalone niet/niut (bounded by non-word on left) followed by | |
| # zero or more punctuation characters and then a non-word boundary. | |
| pattern = re.compile( | |
| r'(?<!\S)(ni[eu]t)([' + punct_class + r']*)(?!\S)', re.IGNORECASE | |
| ) | |
| def _replace(m): | |
| word = m.group(1) | |
| trailing = m.group(2) | |
| if word in ('niet', 'niut'): | |
| result = 'niht' | |
| elif word in ('Niet', 'Niut'): | |
| result = 'Niht' | |
| elif word in ('NIET', 'NIUT'): | |
| result = 'NIHT' | |
| else: | |
| # Mixed case — preserve first letter case | |
| result = (word[0].upper() if word[0].isupper() else word[0].lower()) + 'iht' | |
| return result + trailing | |
| return pattern.sub(_replace, text) | |
| def _common_apocopes(text: str) -> str: | |
| """Apply common MHG apocope (final vowel/consonant loss) rules. | |
| In Middle High German, certain words lose their final syllables in | |
| common usage. This post-processing step handles the most frequent | |
| apocopes: | |
| Examples: | |
| 'wile' → 'wil' | |
| 'vore' → 'vor' | |
| Args: | |
| text: Input text to process | |
| Returns: | |
| Text with common apocopes applied | |
| """ | |
| # Build character class of all editorial punctuation so we can | |
| # capture trailing punctuation and re-attach it after replacement. | |
| punct_class = re.escape(''.join(_PUNCT_CHARS)) | |
| replacements = { | |
| 'vile': 'vil', | |
| 'vore': 'vor', | |
| 'wile': 'wil', | |
| 'wole': 'wol', | |
| } | |
| # Build a single regex that matches any of the keys as whole words | |
| # (bounded by whitespace or start/end), followed by optional punctuation. | |
| pattern_str = r'(?<!\S)(' + '|'.join(re.escape(k) for k in replacements.keys()) + r')([' + punct_class + r']*)(?!\S)' | |
| pattern = re.compile(pattern_str, re.IGNORECASE) | |
| def _replace(m): | |
| word = m.group(1) | |
| trailing = m.group(2) | |
| result = replacements.get(word.lower(), word) | |
| # Preserve capitalization pattern from the original word | |
| if word[0].isupper() and result[0].islower(): | |
| result = result[0].upper() + result[1:] | |
| return result + trailing | |
| return pattern.sub(_replace, text) | |
| def _capitalize_first_alpha(word: str) -> str: | |
| """Capitalize the first alphabetic character in a word. | |
| Handles words that may start with non-alphabetic characters | |
| (e.g., MHG abbreviation markers like hyphens or apostrophes, | |
| or leading punctuation that was not stripped). | |
| Args: | |
| word: Word to capitalize | |
| Returns: | |
| Word with first alphabetic character uppercased | |
| """ | |
| for i, ch in enumerate(word): | |
| if ch.isalpha(): | |
| return word[:i] + ch.upper() + word[i+1:] | |
| return word # No alphabetic characters found; return unchanged | |
| def _select_device() -> torch.device: | |
| """Select the best available device: CUDA > ROCm > DirectML > MPS > CPU.""" | |
| if torch.cuda.is_available(): | |
| return torch.device('cuda') | |
| if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): | |
| return torch.device('mps') | |
| try: | |
| import torch_directml | |
| device = torch_directml.device() | |
| return torch.device(device) | |
| except ImportError: | |
| pass | |
| return torch.device('cpu') | |
| class MHGNormalizer: | |
| """Normalizer for Middle High German text.""" | |
| def __init__(self, model_path: str, config_path: Optional[str] = None, defer_gpu: bool = False): | |
| """ | |
| Initialize normalizer with trained model. | |
| Args: | |
| model_path: Path to trained model checkpoint | |
| config_path: Path to configuration file (optional) | |
| """ | |
| self.device = _select_device() | |
| print(f"Using device: {self.device}") | |
| # Load configuration if provided | |
| if config_path: | |
| with open(config_path, 'r', encoding='utf-8') as f: | |
| self.config = yaml.safe_load(f) | |
| else: | |
| # Default configuration | |
| self.config = { | |
| 'inference': { | |
| 'max_length': 512, | |
| 'num_beams': 5, | |
| 'length_penalty': 0.6, | |
| 'do_sample': False, | |
| 'top_p': 0.9, | |
| 'temperature': 1.0, | |
| 'stride': True, | |
| 'stride_window_tokens': 400, | |
| 'stride_step_tokens': 300, | |
| } | |
| } | |
| # Ensure stride config exists (for configs created before stride was added) | |
| inference_config = self.config.setdefault('inference', {}) | |
| if 'stride' not in inference_config: | |
| inference_config['stride'] = True | |
| if 'stride_window_tokens' not in inference_config: | |
| inference_config['stride_window_tokens'] = 400 | |
| if 'stride_step_tokens' not in inference_config: | |
| inference_config['stride_step_tokens'] = 300 | |
| # Ensure preserve_punctuation and preserve_capitalization defaults | |
| if 'preserve_punctuation' not in inference_config: | |
| inference_config['preserve_punctuation'] = True | |
| if 'preserve_capitalization' not in inference_config: | |
| inference_config['preserve_capitalization'] = True | |
| # Ensure post_processing defaults | |
| post_config = self.config.setdefault('post_processing', {}) | |
| if 'attach_en_proclitic' not in post_config: | |
| post_config['attach_en_proclitic'] = False | |
| if 'lenition_t_after_n' not in post_config: | |
| post_config['lenition_t_after_n'] = False | |
| if 'lenition_t_after_l' not in post_config: | |
| post_config['lenition_t_after_l'] = False | |
| if 'niet_to_niht' not in post_config: | |
| post_config['niet_to_niht'] = False | |
| if 'common_apocopes' not in post_config: | |
| post_config['common_apocopes'] = False | |
| # Load model and tokenizer | |
| print(f"\nLoading model from: {model_path}") | |
| self.tokenizer = AutoTokenizer.from_pretrained(model_path) | |
| # Detect dtype from model config (FP16 models have torch_dtype=float16) | |
| import json as _json | |
| from pathlib import Path as _Path | |
| _cfg = _Path(model_path) / "config.json" | |
| if _cfg.exists(): | |
| _cfg_data = _json.loads(_cfg.read_text()) | |
| _model_dtype = torch.float16 if _cfg_data.get("torch_dtype") == "float16" else None | |
| else: | |
| _model_dtype = None | |
| # CPU lacks efficient FP16 support — load as FP32 there. On CUDA/ZeroGPU | |
| # load FP16 weights as bfloat16, which is numerically stable for T5/mT5 | |
| # (FP16 tends to overflow/NaN in T5) and fast on modern GPUs. | |
| # When defer_gpu=True (ZeroGPU), the model is preloaded to CPU at | |
| # startup and moved to GPU later inside @spaces.GPU — load as BF16 | |
| # so the GPU transfer is fast and inference runs in BF16. | |
| if _model_dtype == torch.float16: | |
| if str(self.device) == "cpu" and not defer_gpu: | |
| print(" CPU detected — loading FP16 model as FP32 (CPU has no FP16 acceleration)") | |
| _load_dtype = torch.float32 | |
| else: | |
| _where = "GPU" if str(self.device) != "cpu" else "CPU (GPU deferred)" | |
| print(f" Loading FP16 model as bfloat16 ({_where})") | |
| _load_dtype = torch.bfloat16 | |
| else: | |
| _load_dtype = _model_dtype | |
| # Do NOT pass tie_word_embeddings=False — T5/mT5 always forces | |
| # tie_word_embeddings=True in T5Config.__post_init__. Passing False | |
| # prevents HuggingFace from tying encoder/decoder embed_tokens to | |
| # shared.weight when loading checkpoints, causing broken inference. | |
| self.model = AutoModelForSeq2SeqLM.from_pretrained( | |
| model_path, | |
| torch_dtype=_load_dtype, | |
| low_cpu_mem_usage=True, | |
| ) | |
| self.model.to(self.device) | |
| self.model.eval() | |
| print("Model loaded successfully!\n") | |
| def _ensure_on_device(self): | |
| """Move the model to the best available device if it has changed. | |
| On ZeroGPU, __init__ runs at startup when no GPU is visible, so the | |
| model loads to CPU. This is called inside @spaces.GPU (where CUDA is | |
| available) to transfer the model to the GPU. Idempotent: if the model | |
| is already on the target device, nothing happens. | |
| """ | |
| target = _select_device() | |
| if target != self.device: | |
| print(f"Moving model from {self.device} to {target} …") | |
| self.device = target | |
| self.model.to(self.device) | |
| def _extract_punctuation(text: str): | |
| """Extract editorial punctuation from text for preservation during normalization. | |
| Strips leading and trailing editorial punctuation characters from each word, | |
| returning the stripped text and a punctuation map for later restoration. | |
| Words that consist entirely of punctuation characters are absorbed into | |
| the trailing punctuation of the previous word (or the leading punctuation | |
| of the next word if they appear at the start of the text). | |
| Args: | |
| text: Input text that may contain editorial punctuation | |
| Returns: | |
| tuple: (stripped_text, punct_map) where: | |
| - stripped_text: text with editorial punctuation removed from word edges | |
| - punct_map: list of (leading_punct, trailing_punct) tuples, one per | |
| word in stripped_text | |
| """ | |
| words = text.split() | |
| punct_map = [] | |
| stripped_words = [] | |
| pending_leading = '' | |
| for word in words: | |
| # Extract leading punctuation | |
| leading = '' | |
| i = 0 | |
| while i < len(word) and word[i] in _PUNCT_CHARS: | |
| leading += word[i] | |
| i += 1 | |
| # Extract trailing punctuation | |
| trailing = '' | |
| j = len(word) - 1 | |
| while j >= i and word[j] in _PUNCT_CHARS: | |
| trailing = word[j] + trailing | |
| j -= 1 | |
| core = word[i:j+1] | |
| if core: | |
| # Prepend any pending leading punctuation (from standalone punct at start) | |
| leading = pending_leading + leading | |
| pending_leading = '' | |
| punct_map.append((leading, trailing)) | |
| stripped_words.append(core) | |
| else: | |
| # Word is entirely punctuation (e.g., "..." or "—") | |
| # Absorb into adjacent word's punctuation. Since ' '.join() provides | |
| # only one space between words, we include an explicit space to | |
| # preserve the whitespace around standalone punctuation marks. | |
| if punct_map: | |
| # Append to previous word's trailing punctuation with leading space | |
| prev_leading, prev_trailing = punct_map[-1] | |
| punct_map[-1] = (prev_leading, prev_trailing + ' ' + leading + trailing) | |
| else: | |
| # At start of text — save as leading punctuation for next word | |
| pending_leading += leading + trailing | |
| # If text ended with standalone punctuation, attach to last word's trailing | |
| if pending_leading and punct_map: | |
| prev_leading, prev_trailing = punct_map[-1] | |
| punct_map[-1] = (prev_leading, prev_trailing + pending_leading) | |
| stripped_text = ' '.join(stripped_words) | |
| return stripped_text, punct_map | |
| def _restore_punctuation(text: str, punct_map: list) -> str: | |
| """Restore editorial punctuation to normalized text. | |
| Uses position-based mapping: word i gets punct_map[i]. When the | |
| model produces a different number of words than the original, | |
| trailing punctuation from unmatched positions is appended to the | |
| last word. | |
| For across-lines mode where word counts may change significantly, | |
| use _restore_punctuation_aligned() instead, which uses character-level | |
| alignment to correctly map punctuation even when words split or merge. | |
| Args: | |
| text: Normalized text (without editorial punctuation) | |
| punct_map: List of (leading_punct, trailing_punct) tuples from | |
| _extract_punctuation | |
| Returns: | |
| Text with editorial punctuation restored | |
| """ | |
| words = text.split() | |
| result = [] | |
| for i, word in enumerate(words): | |
| if i < len(punct_map): | |
| leading, trailing = punct_map[i] | |
| result.append(leading + word + trailing) | |
| else: | |
| # More words in output than in punct_map; no punctuation info | |
| result.append(word) | |
| # If model produced fewer words than original, collect trailing punctuation | |
| # from unmatched positions and append to the last output word. | |
| # This preserves sentence-final punctuation (e.g., trailing periods). | |
| if len(words) > 0 and len(words) < len(punct_map): | |
| extra_trailing = ''.join(t for _, t in punct_map[len(words):]) | |
| if extra_trailing: | |
| result[-1] = result[-1] + extra_trailing | |
| return ' '.join(result) | |
| def _restore_punctuation_aligned(original_text: str, normalized_text: str, | |
| punct_map: list) -> str: | |
| """Restore editorial punctuation using character-level alignment. | |
| When the model changes word counts (e.g., "inalten" → "in alten"), | |
| position-based _restore_punctuation() fails because word indices shift. | |
| This method uses character-level sequence alignment to correctly map | |
| each normalized word to its corresponding original word, then applies | |
| the original word's punctuation. | |
| For example, if "inalten" (with no punctuation) splits into "in alten", | |
| character alignment maps "in" to the start of "inalten" and "alten" to | |
| the end, so both correctly inherit the punctuation of "inalten". | |
| Args: | |
| original_text: Original text with punctuation already stripped | |
| normalized_text: Normalized text (without editorial punctuation) | |
| punct_map: List of (leading_punct, trailing_punct) tuples, | |
| one per word in original_text | |
| Returns: | |
| Text with editorial punctuation restored | |
| """ | |
| normalized_words = normalized_text.split() | |
| if not normalized_words: | |
| return normalized_text | |
| original_words = original_text.split() | |
| if not original_words: | |
| return normalized_text | |
| # If word counts match, use simple position-based restoration | |
| if len(normalized_words) == len(original_words): | |
| return MHGNormalizer._restore_punctuation(normalized_text, punct_map) | |
| # Use character-level alignment to map each normalized word to its | |
| # corresponding original word index. This correctly handles word | |
| # splits like "inalten" → "in alten" because character alignment | |
| # maps "in" to the "in" prefix of "inalten". | |
| norm_to_orig_word = MHGNormalizer._align_normalized_to_original_charlevel( | |
| original_text, normalized_text | |
| ) | |
| # Apply punctuation, handling word splits: | |
| # When multiple normalized words map to the same original word, | |
| # only the first gets leading punct and only the last gets trailing punct. | |
| result = [] | |
| for i, word in enumerate(normalized_words): | |
| orig_idx = norm_to_orig_word[i] | |
| if orig_idx < len(punct_map): | |
| leading, trailing = punct_map[orig_idx] | |
| else: | |
| leading, trailing = '', '' | |
| # Check if this is the first/last normalized word mapping | |
| # to this original word (handles word splits) | |
| is_first = (i == 0 or norm_to_orig_word[i - 1] != orig_idx) | |
| is_last = (i == len(normalized_words) - 1 or | |
| norm_to_orig_word[i + 1] != orig_idx) | |
| applied_leading = leading if is_first else '' | |
| applied_trailing = trailing if is_last else '' | |
| result.append(applied_leading + word + applied_trailing) | |
| # Collect trailing punctuation from unmatched original words | |
| # (original words that were deleted by the model) | |
| matched_orig = set(norm_to_orig_word) | |
| extra_trailing = '' | |
| for orig_idx in range(len(punct_map)): | |
| if orig_idx not in matched_orig: | |
| _, trailing = punct_map[orig_idx] | |
| extra_trailing += trailing | |
| if extra_trailing and result: | |
| result[-1] = result[-1] + extra_trailing | |
| return ' '.join(result) | |
| def _extract_capitalization(text: str): | |
| """Extract capitalization pattern from text for preservation during normalization. | |
| Records which words start with an uppercase letter, then lowercases | |
| the text so the model receives consistent lowercase input. | |
| Words whose first alphabetic character is uppercase are marked in the | |
| cap_map. Non-alphabetic leading characters (e.g., punctuation that | |
| wasn't stripped, MHG abbreviation markers) are skipped when finding | |
| the first alphabetic character. | |
| This method should be called AFTER _extract_punctuation so that | |
| the cap_map indices align with the punct_map indices (both indexed | |
| by words in the punctuation-stripped text). | |
| Args: | |
| text: Input text (typically with punctuation already stripped) | |
| Returns: | |
| tuple: (lowercased_text, cap_map) where: | |
| - lowercased_text: the text converted to lowercase | |
| - cap_map: list of bools, one per word; True if the word's | |
| first alphabetic character was uppercase | |
| """ | |
| words = text.split() | |
| cap_map = [] | |
| for word in words: | |
| has_cap = False | |
| for ch in word: | |
| if ch.isalpha(): | |
| has_cap = ch.isupper() | |
| break | |
| cap_map.append(has_cap) | |
| lowered_text = text.lower() | |
| return lowered_text, cap_map | |
| def _restore_capitalization(text: str, cap_map: list) -> str: | |
| """Restore capitalization to normalized text using position-based mapping. | |
| For each word at position i, if cap_map[i] is True, the first | |
| alphabetic character of that word is uppercased. This is the | |
| simple position-based approach used when word counts match. | |
| For across-lines mode where word counts may change significantly, | |
| use _restore_capitalization_aligned() instead. | |
| Args: | |
| text: Normalized text (lowercase) | |
| cap_map: List of booleans from _extract_capitalization | |
| Returns: | |
| Text with capitalization restored | |
| """ | |
| words = text.split() | |
| result = [] | |
| for i, word in enumerate(words): | |
| if i < len(cap_map) and cap_map[i]: | |
| result.append(_capitalize_first_alpha(word)) | |
| else: | |
| result.append(word) | |
| # If model produced fewer words than original, trailing cap_map | |
| # entries are simply ignored (no capitalization to apply). | |
| return ' '.join(result) | |
| def _restore_capitalization_aligned(original_text: str, normalized_text: str, | |
| cap_map: list) -> str: | |
| """Restore capitalization using character-level alignment. | |
| When the model changes word counts (e.g., "inalten" → "in alten"), | |
| position-based _restore_capitalization() fails because word indices | |
| shift. This method uses character-level sequence alignment to correctly | |
| map each normalized word to its corresponding original word, then | |
| applies capitalization based on the original word's case. | |
| For word splits (multiple normalized words mapping to one original | |
| word), only the first normalized word is capitalized, matching the | |
| original word's pattern. | |
| Args: | |
| original_text: Original text (lowercase, punctuation-stripped; | |
| same text that was sent to the model) | |
| normalized_text: Normalized text (lowercase, without punctuation) | |
| cap_map: List of booleans from _extract_capitalization, | |
| one per word in original_text | |
| Returns: | |
| Text with capitalization restored | |
| """ | |
| normalized_words = normalized_text.split() | |
| if not normalized_words: | |
| return normalized_text | |
| original_words = original_text.split() | |
| if not original_words: | |
| return normalized_text | |
| # If word counts match, use simple position-based restoration | |
| if len(normalized_words) == len(original_words): | |
| return MHGNormalizer._restore_capitalization(normalized_text, cap_map) | |
| # Use character-level alignment to map normalized words to original words | |
| norm_to_orig_word = MHGNormalizer._align_normalized_to_original_charlevel( | |
| original_text, normalized_text | |
| ) | |
| # Apply capitalization, handling word splits: | |
| # When multiple normalized words map to the same original word, | |
| # only the first gets capitalized (matching the original word's | |
| # pattern where only the first letter is uppercase). | |
| result = [] | |
| for i, word in enumerate(normalized_words): | |
| orig_idx = norm_to_orig_word[i] | |
| if orig_idx < len(cap_map) and cap_map[orig_idx]: | |
| is_first = (i == 0 or norm_to_orig_word[i - 1] != orig_idx) | |
| if is_first: | |
| result.append(_capitalize_first_alpha(word)) | |
| else: | |
| result.append(word) | |
| else: | |
| result.append(word) | |
| return ' '.join(result) | |
| def _normalize_word_for_alignment(word: str) -> str: | |
| """Normalize a word for alignment matching. | |
| Converts to lowercase and simplifies common MHG spelling variations | |
| to improve SequenceMatcher's ability to find correct alignments. | |
| Args: | |
| word: Word to normalize | |
| Returns: | |
| Normalized word form for matching | |
| """ | |
| # Lowercase and long-s to short-s | |
| result = word.lower().replace('ſ', 's') | |
| # Remove diacritics for matching | |
| for char, replacement in [ | |
| ('â', 'a'), ('î', 'i'), ('û', 'u'), ('ô', 'o'), | |
| ('ä', 'a'), ('ë', 'e'), ('ï', 'i'), ('ö', 'o'), ('ü', 'u'), | |
| ('é', 'e'), ('è', 'e'), ('ê', 'e'), ('à', 'a'), | |
| ('â', 'a'), ('î', 'i'), ('ô', 'o'), ('û', 'u'), | |
| ]: | |
| result = result.replace(char, replacement) | |
| return result | |
| def _align_normalized_to_original_charlevel( | |
| original_text: str, | |
| normalized_text: str | |
| ) -> List[int]: | |
| """Align normalized words to original words using word-level sequence matching. | |
| Uses difflib.SequenceMatcher at the word level with normalized word forms | |
| to find the best alignment. This handles: | |
| - Word splits (one original → multiple normalized) | |
| - Word changes (different spelling) | |
| - Repeated words (matched by sequence context, not just identity) | |
| Args: | |
| original_text: Original text (space-joined words, punctuation-stripped) | |
| normalized_text: Normalized text (space-joined words) | |
| Returns: | |
| List of original word indices, one per normalized word. | |
| Each normalized word is mapped to the index of the original word | |
| it aligns to. The mapping is monotonically non-decreasing. | |
| """ | |
| normalized_words = normalized_text.split() | |
| if not normalized_words: | |
| return [] | |
| original_words = original_text.split() | |
| if not original_words: | |
| return [0] * len(normalized_words) | |
| total_orig = len(original_words) | |
| total_norm = len(normalized_words) | |
| # If word counts match, use simple position-based mapping | |
| if total_norm == total_orig: | |
| return list(range(total_norm)) | |
| # Use word-level SequenceMatcher with normalized word forms for better matching | |
| # This helps match "dise" to "diſe" and "vâhten" to "vochten" as equal blocks | |
| orig_normalized = [MHGNormalizer._normalize_word_for_alignment(w) for w in original_words] | |
| norm_normalized = [MHGNormalizer._normalize_word_for_alignment(w) for w in normalized_words] | |
| matcher = difflib.SequenceMatcher(None, orig_normalized, norm_normalized, | |
| autojunk=False) | |
| # Build the mapping from normalized word index to original word index | |
| norm_to_orig_word = [0] * total_norm | |
| for tag, i1, i2, j1, j2 in matcher.get_opcodes(): | |
| if tag == 'equal': | |
| # Direct match: normalized words j1:j2 match original words i1:i2 | |
| for k in range(j2 - j1): | |
| norm_to_orig_word[j1 + k] = i1 + k | |
| elif tag == 'replace': | |
| # Words changed but position preserved (e.g., "vō" → "von"). | |
| # Map proportionally within the block using rounded division so | |
| # that word splits (e.g., 1 orig → 2 norm) correctly cluster on | |
| # the same original word rather than drifting to neighbours. | |
| orig_len = i2 - i1 | |
| norm_len = j2 - j1 | |
| for k in range(norm_len): | |
| if orig_len > 0: | |
| # Rounded proportional index: maps k ∈ [0, norm_len) to | |
| # orig_idx ∈ [i1, i1+orig_len). When norm_len > orig_len | |
| # (word splits), multiple k values round to the same | |
| # orig_idx, keeping split fragments together. | |
| orig_idx = i1 + min(round(k * orig_len / norm_len), orig_len - 1) | |
| else: | |
| orig_idx = i1 | |
| norm_to_orig_word[j1 + k] = min(orig_idx, total_orig - 1) | |
| elif tag == 'insert': | |
| # Extra words in normalized (e.g., from word splits). | |
| # Map to the next original word position (i1), since inserts in | |
| # normalization are typically split fragments that belong to the | |
| # following original word, not the preceding one. | |
| for j in range(j1, j2): | |
| if i1 < total_orig: | |
| norm_to_orig_word[j] = i1 | |
| else: | |
| norm_to_orig_word[j] = total_orig - 1 | |
| # 'delete': original words removed, no normalized words to map | |
| # Ensure monotonicity: each normalized word should map to an original | |
| # word at or after the previous normalized word's mapping | |
| prev_orig = 0 | |
| for i in range(total_norm): | |
| if norm_to_orig_word[i] < prev_orig: | |
| norm_to_orig_word[i] = prev_orig | |
| prev_orig = norm_to_orig_word[i] | |
| return norm_to_orig_word | |
| def _spread_repeated_mappings( | |
| norm_to_orig_word: List[int], | |
| original_words: List[str], | |
| normalized_words: List[str] | |
| ) -> List[int]: | |
| """Spread repeated word mappings across their occurrences in the original. | |
| When the character-level alignment maps multiple identical normalized words | |
| to the same original position, this post-processing step spreads them across | |
| the actual occurrences of that word in the original text. | |
| For example, if "dise" appears 6 times in the original (at indices 0, 4, 8, 12, 16, 20) | |
| and 6 normalized "dise" words all map to orig[0], this spreads them to | |
| [0, 4, 8, 12, 16, 20]. | |
| Args: | |
| norm_to_orig_word: Initial alignment mapping | |
| original_words: List of original words | |
| normalized_words: List of normalized words | |
| Returns: | |
| Adjusted alignment mapping with repeated words spread across occurrences | |
| """ | |
| if not norm_to_orig_word: | |
| return norm_to_orig_word | |
| # Build a map of normalized word -> list of original indices where it appears | |
| orig_word_occurrences = {} | |
| for i, word in enumerate(original_words): | |
| word_lower = word.lower() | |
| if word_lower not in orig_word_occurrences: | |
| orig_word_occurrences[word_lower] = [] | |
| orig_word_occurrences[word_lower].append(i) | |
| # Track usage of each original word occurrence | |
| orig_usage = {} # orig_idx -> count of times used | |
| # Track which normalized word forms we've seen and how many times | |
| norm_word_count = {} # normalized_word -> count seen so far | |
| result = [] | |
| for i, orig_idx in enumerate(norm_to_orig_word): | |
| norm_word_lower = normalized_words[i].lower() | |
| # Check if this normalized word form has multiple occurrences in original | |
| occurrences = orig_word_occurrences.get(norm_word_lower, []) | |
| if len(occurrences) > 1: | |
| # This word appears multiple times in original | |
| # Use the next available occurrence | |
| count_so_far = norm_word_count.get(norm_word_lower, 0) | |
| norm_word_count[norm_word_lower] = count_so_far + 1 | |
| if count_so_far < len(occurrences): | |
| # Map to the next occurrence | |
| new_orig_idx = occurrences[count_so_far] | |
| result.append(new_orig_idx) | |
| orig_usage[new_orig_idx] = orig_usage.get(new_orig_idx, 0) + 1 | |
| else: | |
| # More occurrences in normalized than original; use alignment | |
| result.append(orig_idx) | |
| orig_usage[orig_idx] = orig_usage.get(orig_idx, 0) + 1 | |
| else: | |
| # Unique word or single occurrence; use alignment | |
| result.append(orig_idx) | |
| orig_usage[orig_idx] = orig_usage.get(orig_idx, 0) + 1 | |
| return result | |
| def _assign_lines_by_word_alignment( | |
| original_lines: List[str], | |
| normalized_text: str, | |
| ) -> List[int]: | |
| """Assign each word in normalized_text to a line index using word-level alignment. | |
| Uses _align_normalized_to_original_charlevel (word-level difflib with | |
| normalized word forms) to map each normalized word to its corresponding | |
| original word index, then looks up which line that original word | |
| belongs to. | |
| This approach is robust to MHG character differences (ſ→s, diacritics, | |
| etc.) because the alignment normalizes word forms before matching. | |
| It also handles word splits correctly — multiple normalized words | |
| mapping to the same original word stay on the same line. | |
| """ | |
| normalized_words = normalized_text.split() | |
| if not normalized_words: | |
| return [] | |
| num_lines = len(original_lines) | |
| full_orig = ' '.join(original_lines) | |
| # Get word-level alignment: each normalized word → original word index | |
| norm_to_orig_word = MHGNormalizer._align_normalized_to_original_charlevel( | |
| full_orig, normalized_text | |
| ) | |
| # Build original word index → line mapping | |
| word_to_line = [] | |
| for line_idx, line_text in enumerate(original_lines): | |
| for _ in line_text.split(): | |
| word_to_line.append(line_idx) | |
| # Map each normalized word to its line via the original word index | |
| norm_word_to_line = [] | |
| prev_line = 0 | |
| for orig_word_idx in norm_to_orig_word: | |
| if orig_word_idx < len(word_to_line): | |
| line_idx = word_to_line[orig_word_idx] | |
| else: | |
| line_idx = prev_line | |
| norm_word_to_line.append(line_idx) | |
| prev_line = line_idx | |
| # Post-processing: fix cross-line split fragments. | |
| # When a word at a line boundary (e.g., "en" from "Engülden" → "en gültin") | |
| # was incorrectly assigned to the previous line, detect it by checking if | |
| # the normalized word is a prefix of the first word of the next original line. | |
| total_norm = len(normalized_words) | |
| if len(original_lines) > 1: | |
| for norm_idx in range(total_norm - 1): | |
| curr_line = norm_word_to_line[norm_idx] | |
| next_line = norm_word_to_line[norm_idx + 1] | |
| # Only care about descending line assignments (word drifted to previous line) | |
| if curr_line < next_line and curr_line < num_lines - 1: | |
| # This word is on an earlier line than the next word. | |
| # Check if it's a split fragment: does the next original line's | |
| # first word start with this normalized word (after normalizing)? | |
| next_orig_line = curr_line + 1 | |
| next_orig_words = original_lines[next_orig_line].split() | |
| if next_orig_words: | |
| first_next_word = next_orig_words[0] | |
| norm_current = MHGNormalizer._normalize_word_for_alignment(normalized_words[norm_idx]) | |
| norm_first_next = MHGNormalizer._normalize_word_for_alignment(first_next_word) | |
| # If the current word is a prefix of the first word of the next original line, | |
| # it's likely a split fragment that belongs on the next line | |
| if (norm_current and | |
| norm_first_next.startswith(norm_current) and | |
| len(norm_current) <= 4): # short fragments are split candidates | |
| norm_word_to_line[norm_idx] = next_line | |
| return norm_word_to_line | |
| def _get_normalized_to_original_char_mapping( | |
| original_text: str, | |
| normalized_text: str | |
| ) -> dict: | |
| """Map each normalized word start position to its corresponding original character position. | |
| Uses character-level SequenceMatcher to align normalized text to original text, | |
| then computes the starting character position of each normalized word and maps | |
| it to the corresponding position in the original text. | |
| This mapping is used to determine which line each normalized word belongs to | |
| when splitting the normalized output back into lines. Unlike word-level alignment, | |
| character-level alignment correctly handles cases where the model merges multiple | |
| short lines into one. | |
| Args: | |
| original_text: Original text (space-joined words, may contain newlines) | |
| normalized_text: Normalized text (space-joined words) | |
| Returns: | |
| Dictionary mapping normalized word index to original character position | |
| """ | |
| normalized_words = normalized_text.split() | |
| if not normalized_words: | |
| return {} | |
| # Use character-level SequenceMatcher to align normalized text to original text | |
| matcher = difflib.SequenceMatcher(None, original_text, normalized_text, | |
| autojunk=False) | |
| # Build character position mapping from normalized to original | |
| norm_to_orig_char_pos = {} | |
| for tag, i1, i2, j1, j2 in matcher.get_opcodes(): | |
| if tag == 'equal': | |
| for k in range(j2 - j1): | |
| norm_to_orig_char_pos[j1 + k] = i1 + k | |
| elif tag == 'replace': | |
| orig_len = i2 - i1 | |
| norm_len = j2 - j1 | |
| if orig_len > 0 and norm_len > 0: | |
| for k in range(norm_len): | |
| norm_to_orig_char_pos[j1 + k] = i1 + min( | |
| k * orig_len // norm_len, orig_len - 1) | |
| elif norm_len > 0: | |
| for k in range(norm_len): | |
| norm_to_orig_char_pos[j1 + k] = i1 if i1 < len(original_text) else len(original_text) - 1 | |
| elif tag == 'insert': | |
| for j in range(j1, j2): | |
| if i1 > 0: | |
| norm_to_orig_char_pos[j] = i1 - 1 | |
| elif i1 < len(original_text): | |
| norm_to_orig_char_pos[j] = i1 | |
| # Compute start position of each normalized word | |
| word_start_positions = [] | |
| pos = 0 | |
| for word in normalized_words: | |
| word_start_positions.append(pos) | |
| pos += len(word) + 1 # +1 for space | |
| # Map each normalized word to its original character position | |
| word_to_orig_char = {} | |
| for i, start_pos in enumerate(word_start_positions): | |
| if start_pos in norm_to_orig_char_pos: | |
| word_to_orig_char[i] = norm_to_orig_char_pos[start_pos] | |
| else: | |
| # Fallback: find nearest mapped position | |
| for p in range(start_pos, -1, -1): | |
| if p in norm_to_orig_char_pos: | |
| word_to_orig_char[i] = norm_to_orig_char_pos[p] | |
| break | |
| else: | |
| word_to_orig_char[i] = 0 | |
| return word_to_orig_char | |
| def _align_normalized_to_original( | |
| original_words: List[str], | |
| normalized_words: List[str] | |
| ) -> List[int]: | |
| """Align normalized words to original words using word-level sequence matching. | |
| .. deprecated:: | |
| Use :meth:`_align_normalized_to_original_charlevel` instead, which | |
| uses character-level alignment and correctly handles word splits at | |
| line boundaries. This word-level method can incorrectly match a | |
| split word fragment (e.g., "in" from "inallen" → "in allen") to a | |
| standalone identical word on a different line. | |
| Uses difflib.SequenceMatcher to map each normalized word to its | |
| corresponding original word index. This handles word splits and merges | |
| within a single line reasonably well, but can produce incorrect line | |
| assignments when a split fragment matches a word on a different line. | |
| Args: | |
| original_words: Words from the joined original text | |
| normalized_words: Words from the model's normalized output | |
| Returns: | |
| List of original word indices, one per normalized word. | |
| Each normalized word is mapped to the index of the original word | |
| it aligns to. For insertions (extra words from splits), the | |
| preceding original word's index is used. | |
| """ | |
| if not normalized_words: | |
| return [] | |
| if not original_words: | |
| return [0] * len(normalized_words) | |
| # Use SequenceMatcher to align normalized words to original words. | |
| # autojunk=False ensures common words (like "und", "der") are still | |
| # used for anchoring the alignment, which improves quality. | |
| matcher = difflib.SequenceMatcher(None, original_words, normalized_words, | |
| autojunk=False) | |
| # Map each normalized word index to its corresponding original word index | |
| norm_to_orig = [0] * len(normalized_words) | |
| for tag, i1, i2, j1, j2 in matcher.get_opcodes(): | |
| if tag == 'equal': | |
| # Direct match: normalized word j corresponds to original word i | |
| for k in range(j2 - j1): | |
| norm_to_orig[j1 + k] = i1 + k | |
| elif tag == 'replace': | |
| # Words changed but position preserved (e.g., "vō" → "von"). | |
| # Map each normalized word to the corresponding original word | |
| # by proportional position within the replace block. | |
| orig_len = i2 - i1 | |
| norm_len = j2 - j1 | |
| for k in range(norm_len): | |
| orig_idx = i1 + min(k * orig_len // norm_len, orig_len - 1) if norm_len > 0 else i1 | |
| norm_to_orig[j1 + k] = orig_idx | |
| elif tag == 'insert': | |
| # Extra words in normalized text (e.g., from word splits like | |
| # "inalten" → "in" "alten"). Assign to the original word just | |
| # before the insertion point. | |
| for j in range(j1, j2): | |
| if i1 > 0: | |
| norm_to_orig[j] = i1 - 1 | |
| elif i1 < len(original_words): | |
| norm_to_orig[j] = i1 | |
| # 'delete': words removed from original, no normalized words to assign | |
| return norm_to_orig | |
| def _get_token_count(self, text: str) -> int: | |
| """ | |
| Get the number of tokens for a text including the 'normalize: ' prefix. | |
| Args: | |
| text: Input text (without prefix) | |
| Returns: | |
| Total token count including prefix and special tokens | |
| """ | |
| prefix = "normalize: " | |
| input_text = prefix + text | |
| return len(self.tokenizer.encode(input_text)) | |
| def _create_word_windows( | |
| self, | |
| words: List[str], | |
| max_window_tokens: int, | |
| stride_step_tokens: int | |
| ) -> List[Tuple[int, int]]: | |
| """ | |
| Create overlapping word windows that fit within the token budget. | |
| Each window is defined by (start_word_idx, end_word_idx) where | |
| end_word_idx is exclusive. Windows overlap so that every word | |
| appears in the interior of at least one window, ensuring good | |
| context for the normalization of each word. | |
| Args: | |
| words: List of words from the input text | |
| max_window_tokens: Maximum number of tokens per window | |
| (including the 'normalize: ' prefix) | |
| stride_step_tokens: Number of tokens to advance between | |
| consecutive windows | |
| Returns: | |
| List of (start_idx, end_idx) tuples defining word windows | |
| """ | |
| if not words: | |
| return [] | |
| prefix = "normalize: " | |
| prefix_tokens = len(self.tokenizer.encode(prefix, add_special_tokens=False)) | |
| effective_max = max_window_tokens - prefix_tokens | |
| if effective_max <= 0: | |
| # Window too small for any content, fall back to single-word windows | |
| return [(i, i + 1) for i in range(len(words))] | |
| # Pre-compute token count for each word (with leading space for joining) | |
| word_token_counts = [] | |
| for w in words: | |
| tc = len(self.tokenizer.encode(" " + w, add_special_tokens=False)) | |
| word_token_counts.append(tc) | |
| # Cumulative token counts for efficient range queries | |
| cum_tokens = [0] | |
| for tc in word_token_counts: | |
| cum_tokens.append(cum_tokens[-1] + tc) | |
| windows = [] | |
| start_idx = 0 | |
| while start_idx < len(words): | |
| # Find the end of this window: the largest end_idx where | |
| # the token count from start_idx to end_idx fits in effective_max | |
| end_idx = start_idx | |
| while end_idx < len(words) and cum_tokens[end_idx + 1] - cum_tokens[start_idx] <= effective_max: | |
| end_idx += 1 | |
| # Ensure at least one word per window (even if it exceeds token limit) | |
| if end_idx == start_idx: | |
| end_idx = start_idx + 1 | |
| windows.append((start_idx, end_idx)) | |
| # If we've covered all words, stop | |
| if end_idx >= len(words): | |
| break | |
| # Advance start by stride_step_tokens worth of words | |
| new_start = start_idx | |
| while new_start < end_idx and cum_tokens[new_start + 1] - cum_tokens[start_idx] < stride_step_tokens: | |
| new_start += 1 | |
| # Ensure we make progress (at least one word advance) | |
| if new_start <= start_idx: | |
| new_start = start_idx + 1 | |
| start_idx = new_start | |
| return windows | |
| def _merge_window_predictions( | |
| self, | |
| total_words: int, | |
| windows: List[Tuple[int, int]], | |
| window_predictions: List[List[str]], | |
| original_words: List[str] | |
| ) -> List[str]: | |
| """ | |
| Merge predictions from overlapping windows, preferring window centers. | |
| For each word position, the prediction from the window where that | |
| word is closest to the center is used. This ensures that words in | |
| overlap regions get the best context-informed prediction, since | |
| the center of a window has the most surrounding context. | |
| When the model produces a different number of words than expected | |
| (due to word splits or merges), sequence alignment is used instead | |
| of proportional mapping to prevent words from disappearing. | |
| Args: | |
| total_words: Total number of words in the original text | |
| windows: List of (start_idx, end_idx) tuples defining word windows | |
| window_predictions: List of word lists, one per window | |
| original_words: Original input words (used as fallback) | |
| Returns: | |
| List of merged prediction words | |
| """ | |
| # Each position stores a list of words (to handle splits where | |
| # multiple predicted words map to one original position). | |
| merged = [None] * total_words | |
| best_distances = [float('inf')] * total_words | |
| for (start_idx, end_idx), pred_words in zip(windows, window_predictions): | |
| # Center of the window | |
| center = (start_idx + end_idx - 1) / 2.0 | |
| expected_len = end_idx - start_idx | |
| if len(pred_words) == expected_len: | |
| # Perfect alignment: direct word-by-word mapping | |
| for i in range(expected_len): | |
| word_pos = start_idx + i | |
| if word_pos < total_words: | |
| distance = abs(word_pos - center) | |
| if distance < best_distances[word_pos]: | |
| best_distances[word_pos] = distance | |
| merged[word_pos] = [pred_words[i]] | |
| else: | |
| # Length mismatch: the model produced a different number of words | |
| # than expected (word splits or merges). Use character-level | |
| # alignment to properly map each predicted word to its original | |
| # position, preventing word disappearance and correctly handling | |
| # word splits (e.g., "inalten" → "in alten"). | |
| window_words = original_words[start_idx:end_idx] | |
| window_text = ' '.join(window_words) | |
| pred_text = ' '.join(pred_words) | |
| alignment = self._align_normalized_to_original_charlevel( | |
| window_text, pred_text | |
| ) | |
| # Group predicted words by their aligned original position. | |
| # Multiple predicted words mapping to the same position | |
| # (e.g., from a split like "inalten" → "in" "alten") are | |
| # collected together so no words are lost. | |
| pos_to_preds = {} | |
| for j, orig_idx in enumerate(alignment): | |
| abs_pos = start_idx + orig_idx | |
| if abs_pos not in pos_to_preds: | |
| pos_to_preds[abs_pos] = [] | |
| pos_to_preds[abs_pos].append(pred_words[j]) | |
| for abs_pos, preds in pos_to_preds.items(): | |
| if abs_pos < total_words: | |
| distance = abs(abs_pos - center) | |
| if distance < best_distances[abs_pos]: | |
| best_distances[abs_pos] = distance | |
| merged[abs_pos] = preds | |
| # Fill any unfilled positions with original words (model deletions | |
| # within a window — preserve the original un-normalized word rather | |
| # than dropping it entirely). | |
| for i in range(total_words): | |
| if merged[i] is None: | |
| merged[i] = [original_words[i]] | |
| # Flatten lists of words into a single word list. | |
| # Splits produce multiple words at one position; merges produce one. | |
| result = [] | |
| for words_at_pos in merged: | |
| result.extend(words_at_pos) | |
| return result | |
| def normalize_long_text( | |
| self, | |
| text: str, | |
| max_length: Optional[int] = None, | |
| stride_window_tokens: Optional[int] = None, | |
| stride_step_tokens: Optional[int] = None, | |
| num_beams: Optional[int] = None, | |
| length_penalty: Optional[float] = None, | |
| do_sample: Optional[bool] = None, | |
| top_p: Optional[float] = None, | |
| temperature: Optional[float] = None, | |
| preserve_punctuation: Optional[bool] = None, | |
| preserve_capitalization: Optional[bool] = None, | |
| progress_callback: Optional[callable] = None | |
| ) -> str: | |
| """ | |
| Normalize a potentially long text using sliding window with stride. | |
| Splits the input text into overlapping windows of words, normalizes | |
| each window independently, then merges the predictions. For each word | |
| position, the prediction from the window where that word is closest | |
| to the center is preferred, as center predictions have the most | |
| surrounding context. | |
| If the text fits within max_length tokens, delegates to normalize_text() | |
| for single-pass normalization (no windowing needed). | |
| Args: | |
| text: Input text to normalize (original MHG) | |
| max_length: Maximum generation length (tokens) | |
| stride_window_tokens: Max input tokens per window (including prefix). | |
| Default: 400 (from config) | |
| stride_step_tokens: Token advance between windows. Controls overlap; | |
| overlap = window - step. Default: 300 (from config) | |
| num_beams: Number of beams for beam search | |
| length_penalty: Length penalty for beam search | |
| do_sample: Whether to use sampling | |
| top_p: Nucleus sampling threshold | |
| temperature: Temperature for sampling | |
| preserve_punctuation: If True, strip editorial punctuation before | |
| normalization and restore it afterwards. | |
| Default: None (read from config, falls back to True) | |
| preserve_capitalization: If True, record capitalization before | |
| normalization (lowercasing the input) and | |
| restore it afterwards. | |
| Default: None (read from config, falls back to True) | |
| Returns: | |
| Normalized text | |
| """ | |
| # Resolve parameters | |
| max_length = max_length or self.config['inference']['max_length'] | |
| stride_window_tokens = stride_window_tokens or self.config['inference'].get('stride_window_tokens', 400) | |
| stride_step_tokens = stride_step_tokens or self.config['inference'].get('stride_step_tokens', 300) | |
| if preserve_punctuation is None: | |
| preserve_punctuation = self.config['inference'].get('preserve_punctuation', True) | |
| if preserve_capitalization is None: | |
| preserve_capitalization = self.config['inference'].get('preserve_capitalization', True) | |
| # Extract editorial punctuation before normalization | |
| punct_map = None | |
| if preserve_punctuation: | |
| text, punct_map = self._extract_punctuation(text) | |
| # Extract capitalization before normalization (after punctuation extraction | |
| # so cap_map indices align with punct_map indices) | |
| cap_map = None | |
| if preserve_capitalization: | |
| text, cap_map = self._extract_capitalization(text) | |
| # Check if stride is actually needed | |
| token_count = self._get_token_count(text) | |
| if token_count <= max_length: | |
| # Text fits in a single window, use simple normalization | |
| result = self.normalize_text( | |
| text, max_length=max_length, stride=False, | |
| num_beams=num_beams, length_penalty=length_penalty, | |
| do_sample=do_sample, top_p=top_p, temperature=temperature, | |
| preserve_punctuation=False, # already stripped | |
| preserve_capitalization=False # already lowercased | |
| ) | |
| if cap_map is not None: | |
| result = self._restore_capitalization(result, cap_map) | |
| if punct_map is not None: | |
| result = self._restore_punctuation(result, punct_map) | |
| return result | |
| # Split text into words | |
| words = text.split() | |
| if not words: | |
| return "" | |
| if len(words) == 1: | |
| # Single word, just normalize directly (even if token count exceeds limit) | |
| result = self.normalize_text( | |
| text, max_length=max_length, stride=False, | |
| num_beams=num_beams, length_penalty=length_penalty, | |
| do_sample=do_sample, top_p=top_p, temperature=temperature, | |
| preserve_punctuation=False, # already stripped | |
| preserve_capitalization=False # already lowercased | |
| ) | |
| if cap_map is not None: | |
| result = self._restore_capitalization(result, cap_map) | |
| if punct_map is not None: | |
| result = self._restore_punctuation(result, punct_map) | |
| return result | |
| # Create overlapping windows | |
| windows = self._create_word_windows(words, stride_window_tokens, stride_step_tokens) | |
| num_windows = len(windows) | |
| # if num_windows > 1: | |
| # print(f" Stride: {token_count} tokens > {max_length} max, " | |
| # f"using {num_windows} windows " | |
| # f"(window={stride_window_tokens}tok, step={stride_step_tokens}tok, " | |
| # f"overlap={stride_window_tokens - stride_step_tokens}tok)") | |
| # Normalize each window | |
| window_predictions = [] | |
| windows_iter = windows | |
| if num_windows > 1: | |
| windows_iter = tqdm(windows, desc=" Progress", | |
| total=num_windows, ncols=100, | |
| bar_format='{desc}: {percentage:3.0f}%|{bar}| {elapsed}') | |
| for wi, (start_idx, end_idx) in enumerate(windows_iter): | |
| window_text = ' '.join(words[start_idx:end_idx]) | |
| prediction = self.normalize_text( | |
| window_text, max_length=max_length, stride=False, | |
| num_beams=num_beams, length_penalty=length_penalty, | |
| do_sample=do_sample, top_p=top_p, temperature=temperature, | |
| preserve_punctuation=False, # already stripped | |
| preserve_capitalization=False # already lowercased | |
| ) | |
| # Report progress scaled to a low range (5-75%) so the UI bar | |
| # reflects the fact that significant post-processing work remains | |
| # after this method returns. With 5 beams, each window is | |
| # expensive, but the caller-side work (character-level alignment | |
| # in _align_normalized_to_original_charlevel, line reassignment, | |
| # cap/punct restoration in normalize_multiline_text) can easily | |
| # take as long as or longer than the window loop itself. | |
| # | |
| # Scale: window 0 → 5%, last window → 75%. | |
| if progress_callback: | |
| pct = 5 + int(wi * 70 / max(num_windows - 1, 1)) | |
| progress_callback(pct, 100) | |
| pred_words = prediction.split() | |
| window_predictions.append(pred_words) | |
| # Merge predictions from overlapping windows | |
| merged_words = self._merge_window_predictions( | |
| len(words), windows, window_predictions, words | |
| ) | |
| # Signal that generation + merge are done. The remaining 25% of | |
| # progress will be consumed by caller-side post-processing | |
| # (alignment, capitalization/punctuation restoration, line splitting) | |
| # before app.py sets progress to 100. | |
| if progress_callback: | |
| progress_callback(78, 100) | |
| result = ' '.join(merged_words) | |
| if cap_map is not None: | |
| # Use character-level alignment for capitalization restoration | |
| # because word counts may change due to splits/merges. | |
| result = self._restore_capitalization_aligned( | |
| text, result, cap_map | |
| ) | |
| if punct_map is not None: | |
| # Use character-level alignment for punctuation restoration | |
| # instead of position-based mapping, because word counts may | |
| # change due to splits/merges in the merge step. | |
| result = self._restore_punctuation_aligned( | |
| text, result, punct_map | |
| ) | |
| return result | |
| def normalize_text( | |
| self, | |
| text: str, | |
| max_length: Optional[int] = None, | |
| num_beams: Optional[int] = None, | |
| length_penalty: Optional[float] = None, | |
| do_sample: Optional[bool] = None, | |
| top_p: Optional[float] = None, | |
| temperature: Optional[float] = None, | |
| stride: Optional[bool] = None, | |
| stride_window_tokens: Optional[int] = None, | |
| stride_step_tokens: Optional[int] = None, | |
| preserve_punctuation: Optional[bool] = None, | |
| preserve_capitalization: Optional[bool] = None, | |
| attach_en_proclitic: Optional[bool] = None, | |
| lenition_t_after_n: Optional[bool] = None, | |
| lenition_t_after_l: Optional[bool] = None, | |
| niet_to_niht: Optional[bool] = None, | |
| common_apocopes: Optional[bool] = None, | |
| progress_callback: Optional[callable] = None | |
| ) -> str: | |
| """ | |
| Normalize a single text string. | |
| When stride=True (default) and the input text exceeds the model's | |
| maximum sequence length, automatically uses sliding-window inference | |
| via normalize_long_text() to process the full text without truncation. | |
| Args: | |
| text: Input text to normalize (original MHG) | |
| max_length: Maximum generation length | |
| num_beams: Number of beams for beam search | |
| length_penalty: Length penalty for beam search | |
| do_sample: Whether to use sampling (vs beam search) | |
| top_p: Nucleus sampling threshold (0.0 to 1.0) | |
| temperature: Temperature for sampling (0.1 to 2.0) | |
| stride: Whether to use sliding window for long texts. | |
| Default: True (from config). Set to False to always truncate. | |
| stride_window_tokens: Max input tokens per window (for stride mode) | |
| stride_step_tokens: Token advance between windows (for stride mode) | |
| preserve_punctuation: If True, strip editorial punctuation before | |
| normalization and restore it afterwards. | |
| Default: None (read from config, falls back to True) | |
| preserve_capitalization: If True, record capitalization before | |
| normalization (lowercasing the input) and | |
| restore it afterwards. | |
| Default: None (read from config, falls back to True) | |
| attach_en_proclitic: If True, attach free-standing negation proclitic | |
| "en" to the following word with a hyphen | |
| (e.g. "en guot" → "en-guot"). Applied as the | |
| final post-processing step. | |
| Default: None (read from config, falls back to False) | |
| Returns: | |
| Normalized text | |
| """ | |
| # Resolve preserve_punctuation | |
| if preserve_punctuation is None: | |
| preserve_punctuation = self.config['inference'].get('preserve_punctuation', True) | |
| # Resolve preserve_capitalization | |
| if preserve_capitalization is None: | |
| preserve_capitalization = self.config['inference'].get('preserve_capitalization', True) | |
| # Resolve post-processing options | |
| if attach_en_proclitic is None: | |
| attach_en_proclitic = self.config.get('post_processing', {}).get('attach_en_proclitic', False) | |
| if lenition_t_after_n is None: | |
| lenition_t_after_n = self.config.get('post_processing', {}).get('lenition_t_after_n', False) | |
| if lenition_t_after_l is None: | |
| lenition_t_after_l = self.config.get('post_processing', {}).get('lenition_t_after_l', False) | |
| if niet_to_niht is None: | |
| niet_to_niht = self.config.get('post_processing', {}).get('niet_to_niht', False) | |
| if common_apocopes is None: | |
| common_apocopes = self.config.get('post_processing', {}).get('common_apocopes', False) | |
| # Extract editorial punctuation before normalization | |
| punct_map = None | |
| if preserve_punctuation: | |
| text, punct_map = self._extract_punctuation(text) | |
| # Extract capitalization before normalization (after punctuation extraction | |
| # so cap_map indices align with punct_map indices) | |
| cap_map = None | |
| if preserve_capitalization: | |
| text, cap_map = self._extract_capitalization(text) | |
| # Resolve stride setting | |
| if stride is None: | |
| stride = self.config['inference'].get('stride', True) | |
| # If stride is enabled, check if we need it | |
| if stride: | |
| max_length_resolved = max_length or self.config['inference']['max_length'] | |
| token_count = self._get_token_count(text) | |
| if token_count > max_length_resolved: | |
| result = self.normalize_long_text( | |
| text, max_length=max_length_resolved, | |
| stride_window_tokens=stride_window_tokens, | |
| stride_step_tokens=stride_step_tokens, | |
| num_beams=num_beams, length_penalty=length_penalty, | |
| do_sample=do_sample, top_p=top_p, temperature=temperature, | |
| preserve_punctuation=False, # already stripped above | |
| preserve_capitalization=False, # already lowercased above | |
| progress_callback=progress_callback | |
| ) | |
| if cap_map is not None: | |
| result = self._restore_capitalization(result, cap_map) | |
| if punct_map is not None: | |
| result = self._restore_punctuation(result, punct_map) | |
| # Post-processing: common apocopes (before en-attachment so "en wile" → "en wil" → "enwil") | |
| if common_apocopes: | |
| result = _common_apocopes(result) | |
| # Post-processing: attach negation proclitic | |
| if attach_en_proclitic: | |
| result = _attach_en_proclitic(result) | |
| # Post-processing: lenition of t after n before vowel | |
| if lenition_t_after_n: | |
| result = _lenition_t_after_n_before_vowel(result) | |
| # Post-processing: lenition of t after l before vowel | |
| if lenition_t_after_l: | |
| result = _lenition_t_after_l_before_vowel(result) | |
| # Post-processing: niet → niht | |
| if niet_to_niht: | |
| result = _niet_to_niht(result) | |
| return result | |
| # Use config values if not provided | |
| max_length = max_length or self.config['inference']['max_length'] | |
| num_beams = num_beams or self.config['inference']['num_beams'] | |
| length_penalty = length_penalty or self.config['inference']['length_penalty'] | |
| do_sample = do_sample if do_sample is not None else self.config['inference']['do_sample'] | |
| top_p = top_p if top_p is not None else self.config['inference']['top_p'] | |
| temperature = temperature if temperature is not None else self.config['inference'].get('temperature', 1.0) | |
| # Add prefix if using T5-style model | |
| prefix = "normalize: " | |
| input_text = prefix + text | |
| # Tokenize | |
| inputs = self.tokenizer( | |
| input_text, | |
| return_tensors='pt', | |
| padding=True, | |
| truncation=True, | |
| max_length=max_length | |
| ) | |
| # Move to device | |
| inputs = {k: v.to(self.device) for k, v in inputs.items()} | |
| # Generate | |
| with torch.no_grad(): | |
| # Prepare generation arguments by unpacking inputs directly | |
| generate_kwargs = { | |
| **inputs, # This includes 'input_ids' and 'attention_mask' | |
| 'max_length': max_length, | |
| 'num_beams': num_beams, | |
| 'length_penalty': length_penalty, | |
| 'do_sample': do_sample, | |
| 'early_stopping': True | |
| } | |
| # Only add sampling-specific parameters if sampling is enabled | |
| if do_sample: | |
| generate_kwargs['top_p'] = top_p | |
| generate_kwargs['temperature'] = temperature | |
| outputs = self.model.generate(**generate_kwargs) | |
| # Decode | |
| normalized = self.tokenizer.decode(outputs[0], skip_special_tokens=True) | |
| # Restore capitalization (before punctuation, since cap_map indices | |
| # align with punctuation-stripped words) | |
| if cap_map is not None: | |
| normalized = self._restore_capitalization(normalized, cap_map) | |
| # Restore editorial punctuation | |
| if punct_map is not None: | |
| normalized = self._restore_punctuation(normalized, punct_map) | |
| # Post-processing: common apocopes (before en-attachment so "en wile" → "en wil" → "enwil") | |
| if common_apocopes: | |
| normalized = _common_apocopes(normalized) | |
| # Post-processing: attach negation proclitic (after apocopes) | |
| if attach_en_proclitic: | |
| normalized = _attach_en_proclitic(normalized) | |
| # Post-processing: lenition of t after n before vowel | |
| if lenition_t_after_n: | |
| normalized = _lenition_t_after_n_before_vowel(normalized) | |
| # Post-processing: lenition of t after l before vowel | |
| if lenition_t_after_l: | |
| normalized = _lenition_t_after_l_before_vowel(normalized) | |
| # Post-processing: niet → niht | |
| if niet_to_niht: | |
| normalized = _niet_to_niht(normalized) | |
| return normalized | |
| def normalize_texts( | |
| self, | |
| texts: List[str], | |
| batch_size: int = 8, | |
| show_progress: bool = True, | |
| **kwargs | |
| ) -> List[str]: | |
| """ | |
| Normalize multiple texts efficiently using batching. | |
| Args: | |
| texts: List of input texts | |
| batch_size: Batch size for processing | |
| show_progress: Whether to show progress bar | |
| **kwargs: Additional arguments for normalize_text | |
| (including stride, stride_window_tokens, stride_step_tokens) | |
| Returns: | |
| List of normalized texts | |
| """ | |
| normalized_texts = [] | |
| # Calculate total number of batches | |
| total_batches = (len(texts) + batch_size - 1) // batch_size | |
| # Create progress bar if requested | |
| iterator = range(0, len(texts), batch_size) | |
| if show_progress: | |
| iterator = tqdm(iterator, desc="Normalizing lines", | |
| total=total_batches, | |
| ncols=100, | |
| bar_format='{desc}: {percentage:3.0f}%|{bar}| {elapsed}') | |
| for i in iterator: | |
| batch = texts[i:i + batch_size] | |
| batch_normalized = [ | |
| self.normalize_text(text, **kwargs) | |
| for text in batch | |
| ] | |
| normalized_texts.extend(batch_normalized) | |
| return normalized_texts | |
| def normalize_file( | |
| self, | |
| input_path: str, | |
| output_path: str, | |
| batch_size: int = 8, | |
| across_lines: bool = True, | |
| preserve_punctuation: Optional[bool] = None, | |
| preserve_capitalization: Optional[bool] = None, | |
| **kwargs | |
| ): | |
| """ | |
| Normalize texts from a file. | |
| When across_lines=True (default), all lines are joined into a single | |
| continuous text and normalized together, utilizing the model's full | |
| capacity and sliding-window stride across line boundaries. The output | |
| is split back into lines based on original word counts per line. | |
| When across_lines=False, each line is normalized independently | |
| (original behavior). | |
| Args: | |
| input_path: Path to input file (one text per line) | |
| output_path: Path to output file | |
| batch_size: Batch size for processing (only used when across_lines=False) | |
| across_lines: If True, join all lines and normalize as one continuous | |
| text, then split back by word counts. Default: True. | |
| preserve_punctuation: If True, strip editorial punctuation before | |
| normalization and restore it afterwards. | |
| Default: None (read from config, falls back to True) | |
| preserve_capitalization: If True, record capitalization before | |
| normalization (lowercasing the input) and | |
| restore it afterwards. | |
| Default: None (read from config, falls back to True) | |
| **kwargs: Additional arguments for normalize_text | |
| (including stride, stride_window_tokens, stride_step_tokens) | |
| """ | |
| input_path = Path(input_path) | |
| output_path = Path(output_path) | |
| # Resolve preserve_punctuation | |
| if preserve_punctuation is None: | |
| preserve_punctuation = self.config['inference'].get('preserve_punctuation', True) | |
| # Resolve preserve_capitalization | |
| if preserve_capitalization is None: | |
| preserve_capitalization = self.config['inference'].get('preserve_capitalization', True) | |
| # Read input file — do NOT lowercase here; capitalization extraction | |
| # handles lowercasing when preserve_capitalization is True, otherwise | |
| # we lowercase explicitly below. | |
| # Preserve empty lines: track their positions so they can be reinserted | |
| # in the output at the same locations. | |
| print(f"\nNormalizing: {input_path}") | |
| with open(input_path, 'r', encoding='utf-8') as f: | |
| all_lines = [line.rstrip('\n').rstrip('\r') for line in f] | |
| # Build a map of which line indices are empty, and extract non-empty | |
| # lines for normalization. Empty lines will be reinserted as-is. | |
| empty_line_indices = set() | |
| texts = [] | |
| for i, line in enumerate(all_lines): | |
| if line.strip() == '': | |
| empty_line_indices.add(i) | |
| else: | |
| texts.append(line.strip()) | |
| # Map from non-empty line index to original line index, so we can | |
| # place normalized output back into the correct positions. | |
| non_empty_to_original = [] | |
| for i, line in enumerate(all_lines): | |
| if line.strip() != '': | |
| non_empty_to_original.append(i) | |
| # If preserve_capitalization is disabled, lowercase all input now | |
| # (the model expects lowercase input). When preserve_capitalization | |
| # is enabled, _extract_capitalization will lowercase the text and | |
| # record the original capitalization pattern for later restoration. | |
| if not preserve_capitalization: | |
| texts = [line.lower() for line in texts] | |
| total_texts = len(texts) | |
| #print(f"Loaded {total_texts} lines") | |
| if across_lines: | |
| # Per-line pre-processing: extract punctuation and capitalization | |
| # before joining into a single text for normalization. | |
| # Order matters: punctuation extraction first (on original-case text), | |
| # then capitalization extraction (on punctuation-stripped text). | |
| # This ensures cap_map and punct_map are both indexed by the same | |
| # word list (punctuation-stripped words), making per-line restoration | |
| # straightforward. | |
| line_punct_maps = None | |
| line_cap_maps = None | |
| stripped_lines = None # punctuation-stripped lines (original case) | |
| lowered_lines = None # lowercase, punctuation-stripped lines | |
| if preserve_punctuation: | |
| line_punct_maps = [] | |
| stripped_lines = [] | |
| for line in texts: | |
| stripped_line, punct_map = self._extract_punctuation(line) | |
| stripped_lines.append(stripped_line) | |
| line_punct_maps.append(punct_map) | |
| if preserve_capitalization: | |
| line_cap_maps = [] | |
| lowered_lines = [] | |
| # Capitalization extraction operates on the punctuation-stripped | |
| # text (if punctuation was extracted) or the original text. | |
| cap_source_lines = stripped_lines if stripped_lines is not None else texts | |
| for line in cap_source_lines: | |
| lowered_line, cap_map = self._extract_capitalization(line) | |
| lowered_lines.append(lowered_line) | |
| line_cap_maps.append(cap_map) | |
| # Determine the text to send to the model: | |
| # - If both preserved: lowered_lines (lowercase, no punctuation) | |
| # - If only cap preserved: lowered_lines (lowercase, with punctuation) | |
| # - If only punct preserved: stripped_lines (original case, no punctuation) | |
| # - If neither preserved: texts (lowercased earlier) | |
| if lowered_lines is not None: | |
| word_counts = [len(line.split()) for line in lowered_lines] | |
| full_text = ' '.join(lowered_lines) | |
| elif stripped_lines is not None: | |
| word_counts = [len(line.split()) for line in stripped_lines] | |
| full_text = ' '.join(stripped_lines) | |
| else: | |
| word_counts = [len(line.split()) for line in texts] | |
| full_text = ' '.join(texts) | |
| normalized_full = self.normalize_text( | |
| full_text, preserve_punctuation=False, | |
| preserve_capitalization=False, **kwargs | |
| ) | |
| # Split normalized output back into lines using occurrence-based tracking | |
| # with word split detection. | |
| # | |
| # This correctly handles: | |
| # - Word splits (e.g., "enwere" → "en wære"): detected via alignment | |
| # - Repeated words (e.g., "dise" x10): occurrence tracking maps k-th occurrence | |
| # in normalized to k-th occurrence in original | |
| # - Different word counts: proportional mapping fallback | |
| num_lines = len(texts) | |
| normalized_words = normalized_full.split() | |
| total_norm = len(normalized_words) | |
| # Build word_to_line mapping from original word indices to line indices | |
| word_to_line = [] | |
| for line_idx, count in enumerate(word_counts): | |
| for _ in range(count): | |
| word_to_line.append(line_idx) | |
| # Calculate total original words | |
| total_orig = sum(word_counts) | |
| # Build original word list for occurrence tracking | |
| original_words = full_text.split() | |
| # Helper function to normalize word for occurrence tracking | |
| def normalize_word_for_tracking(word: str) -> str: | |
| """Normalize word for occurrence tracking (case-insensitive, ſ→s, simplified).""" | |
| # Basic normalization: lowercase and long-s to short-s | |
| base = word.lower().replace('ſ', 's') | |
| # Simplify common MHG spelling variations for matching | |
| # Remove diacritics for matching purposes | |
| simplified = base | |
| for char, replacement in [ | |
| ('â', 'a'), ('î', 'i'), ('û', 'u'), ('ô', 'o'), | |
| ('ä', 'a'), ('ë', 'e'), ('ï', 'i'), ('ö', 'o'), ('ü', 'u'), | |
| ('é', 'e'), ('è', 'e'), ('ê', 'e'), ('à', 'a'), | |
| ('â', 'a'), ('î', 'i'), ('ô', 'o'), ('û', 'u'), | |
| ]: | |
| simplified = simplified.replace(char, replacement) | |
| return simplified | |
| # Step 1: Build occurrence tracking for original words | |
| # Maps normalized_word -> list of original indices where it appears | |
| orig_occurrences = {} | |
| for i, word in enumerate(original_words): | |
| norm_word = normalize_word_for_tracking(word) | |
| if norm_word not in orig_occurrences: | |
| orig_occurrences[norm_word] = [] | |
| orig_occurrences[norm_word].append(i) | |
| # Get character-level alignment first (used in Steps 2 and 3) | |
| norm_to_orig_word = self._align_normalized_to_original_charlevel( | |
| full_text, normalized_full | |
| ) | |
| # Step 2: Map each normalized word to original | |
| # Use alignment as primary method. For word splits (multiple normalized | |
| # words mapping to the same original), the split detection in Step 3 | |
| # handles keeping them on the same line. | |
| # | |
| # The occurrence-based approach was removed because it caused incorrect | |
| # mappings: when a word like "daz" appears multiple times in the original, | |
| # counting global occurrences led to mapping normalized words to wrong | |
| # positions (e.g., line 10's "daz" mapped to line 4's "das"). | |
| # | |
| # The character-level alignment correctly handles: | |
| # - Word splits (e.g., "inalten" → "in alten") | |
| # - Word merges | |
| # - Position-based mapping for non-split words | |
| norm_to_orig_occurrence = norm_to_orig_word.copy() | |
| # Step 3: Use alignment to detect word splits | |
| # A true word split is when consecutive normalized words map to the same original | |
| # AND the original word is longer (contains the split parts) | |
| # We filter out false splits caused by repeated words like "dise" | |
| # (norm_to_orig_word already computed above for fallback) | |
| # Detect split groups: consecutive normalized words that map to the same original | |
| # These should stay on the same line | |
| # BUT only if it's a true word split (original word contains the parts) | |
| split_group = {} # norm_idx -> group_id | |
| group_id = 0 | |
| i = 0 | |
| while i < total_norm: | |
| if i == total_norm - 1: | |
| # Last word, no split possible | |
| split_group[i] = group_id | |
| break | |
| # Check if current and next word map to same original (potential word split) | |
| if norm_to_orig_word[i] == norm_to_orig_word[i + 1]: | |
| current_orig = norm_to_orig_word[i] | |
| orig_word = original_words[current_orig] | |
| # Collect consecutive words mapping to this original | |
| split_words = [] | |
| j = i | |
| while j < total_norm and norm_to_orig_word[j] == current_orig: | |
| split_words.append(normalized_words[j]) | |
| j += 1 | |
| # Check if this is a true word split: | |
| # The combined normalized words should be similar to the original | |
| # (allowing for minor spelling differences) | |
| combined = ''.join(split_words) | |
| is_true_split = ( | |
| len(combined) >= len(orig_word) * 0.5 and # Combined is at least half the original length | |
| len(combined) <= len(orig_word) * 1.5 and # Combined is at most 1.5x original length | |
| len(split_words) > 1 # At least 2 parts | |
| ) | |
| if is_true_split: | |
| # True word split - keep parts together | |
| for k in range(i, j): | |
| split_group[k] = group_id | |
| group_id += 1 | |
| i = j | |
| else: | |
| # False split (repeated words) - assign each word its own group | |
| for k in range(i, j): | |
| split_group[k] = group_id | |
| group_id += 1 | |
| i = j | |
| else: | |
| split_group[i] = group_id | |
| group_id += 1 | |
| i += 1 | |
| # Assign words to lines using word-level alignment | |
| norm_to_line = self._assign_lines_by_word_alignment( | |
| lowered_lines if lowered_lines is not None else | |
| (stripped_lines if stripped_lines is not None else texts), | |
| normalized_full | |
| ) | |
| line_word_lists = [[] for _ in range(num_lines)] | |
| for norm_idx in range(total_norm): | |
| target_line = norm_to_line[norm_idx] | |
| line_word_lists[target_line].append(normalized_words[norm_idx]) | |
| # Ensure no empty lines by redistributing words if necessary | |
| # This can happen when the model produces very different structure | |
| empty_lines = [i for i in range(num_lines) if not line_word_lists[i]] | |
| if empty_lines: | |
| # Find lines with multiple words and move some to empty lines | |
| for empty_idx in empty_lines: | |
| # Find the nearest line with words | |
| best_source = None | |
| best_dist = float('inf') | |
| for src_idx, words in enumerate(line_word_lists): | |
| if len(words) > 1: | |
| dist = abs(src_idx - empty_idx) | |
| if dist < best_dist: | |
| best_dist = dist | |
| best_source = src_idx | |
| if best_source is not None: | |
| # Move the last word from source to empty line | |
| word = line_word_lists[best_source].pop() | |
| line_word_lists[empty_idx].append(word) | |
| normalized = [] | |
| for line_idx in range(num_lines): | |
| line_text = ' '.join(line_word_lists[line_idx]) | |
| # Restore capitalization first (normalized output is lowercase) | |
| if preserve_capitalization and line_cap_maps is not None and line_idx < len(line_cap_maps): | |
| cap_ref = lowered_lines[line_idx] if lowered_lines is not None else None | |
| if cap_ref is not None: | |
| line_text = self._restore_capitalization_aligned( | |
| cap_ref, line_text, line_cap_maps[line_idx] | |
| ) | |
| # Then restore punctuation (on capitalized text) | |
| if preserve_punctuation and line_punct_maps is not None and line_idx < len(line_punct_maps): | |
| punct_ref = stripped_lines[line_idx] if stripped_lines is not None else None | |
| if punct_ref is not None: | |
| line_text = self._restore_punctuation_aligned( | |
| punct_ref, line_text, line_punct_maps[line_idx] | |
| ) | |
| normalized.append(line_text) | |
| # Ensure we have the right number of output lines | |
| while len(normalized) < total_texts: | |
| normalized.append('') | |
| else: | |
| # Original behavior: normalize each line independently | |
| # Forward preserve_punctuation and preserve_capitalization explicitly | |
| # so normalize_text doesn't fall back to the config default | |
| kwargs['preserve_punctuation'] = preserve_punctuation | |
| kwargs['preserve_capitalization'] = preserve_capitalization | |
| print() | |
| normalized = self.normalize_texts( | |
| texts, | |
| batch_size=batch_size, | |
| show_progress=True, | |
| **kwargs | |
| ) | |
| # Reinsert empty lines at their original positions. | |
| # normalized[] contains results for non-empty lines only; we need | |
| # to merge them back into the full line sequence, preserving empty | |
| # lines at the same positions as the input. | |
| total_original_lines = len(all_lines) | |
| if empty_line_indices: | |
| full_output = [''] * total_original_lines | |
| for norm_idx, orig_idx in enumerate(non_empty_to_original): | |
| if norm_idx < len(normalized) and orig_idx < total_original_lines: | |
| full_output[orig_idx] = normalized[norm_idx] | |
| # Any remaining normalized lines beyond the mapping go at the end | |
| for norm_idx in range(len(non_empty_to_original), len(normalized)): | |
| full_output.append(normalized[norm_idx]) | |
| output_lines = full_output | |
| else: | |
| output_lines = normalized | |
| # Write output | |
| print(f"\nWriting to: {output_path}") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| for text in output_lines: | |
| f.write(text + '\n') | |
| print("Done!") | |
| def normalize_multiline_text( | |
| self, | |
| input_text: str, | |
| preserve_punctuation: Optional[bool] = None, | |
| preserve_capitalization: Optional[bool] = None, | |
| attach_en_proclitic: Optional[bool] = None, | |
| lenition_t_after_n: Optional[bool] = None, | |
| lenition_t_after_l: Optional[bool] = None, | |
| niet_to_niht: Optional[bool] = None, | |
| common_apocopes: Optional[bool] = None, | |
| progress_callback: Optional[callable] = None, | |
| **kwargs | |
| ) -> str: | |
| """ | |
| Normalize multi-line text preserving line structure with cross-line context. | |
| Replicates the across_lines=True logic from normalize_file() but operates | |
| on a string rather than a file. | |
| Returns normalized text with the same number of lines as input. | |
| """ | |
| if self.config is not None: | |
| if preserve_punctuation is None: | |
| preserve_punctuation = self.config.get('preserve_editorial_punctuation', False) | |
| if preserve_capitalization is None: | |
| preserve_capitalization = self.config.get('preserve_capitalization', False) | |
| if attach_en_proclitic is None: | |
| attach_en_proclitic = self.config.get('post_processing', {}).get('attach_en_proclitic', False) | |
| if lenition_t_after_n is None: | |
| lenition_t_after_n = self.config.get('post_processing', {}).get('lenition_t_after_n', False) | |
| if lenition_t_after_l is None: | |
| lenition_t_after_l = self.config.get('post_processing', {}).get('lenition_t_after_l', False) | |
| if niet_to_niht is None: | |
| niet_to_niht = self.config.get('post_processing', {}).get('niet_to_niht', False) | |
| if common_apocopes is None: | |
| common_apocopes = self.config.get('post_processing', {}).get('common_apocopes', False) | |
| else: | |
| preserve_punctuation = preserve_punctuation if preserve_punctuation is not None else False | |
| preserve_capitalization = preserve_capitalization if preserve_capitalization is not None else False | |
| attach_en_proclitic = attach_en_proclitic if attach_en_proclitic is not None else False | |
| lenition_t_after_n = lenition_t_after_n if lenition_t_after_n is not None else False | |
| lenition_t_after_l = lenition_t_after_l if lenition_t_after_l is not None else False | |
| niet_to_niht = niet_to_niht if niet_to_niht is not None else False | |
| common_apocopes = common_apocopes if common_apocopes is not None else False | |
| all_lines = [line.rstrip('\n').rstrip('\r') for line in input_text.split('\n')] | |
| # Build empty-line map and extract non-empty lines | |
| empty_line_indices = set() | |
| texts = [] | |
| for i, line in enumerate(all_lines): | |
| if line.strip() == '': | |
| empty_line_indices.add(i) | |
| else: | |
| texts.append(line.strip()) | |
| non_empty_to_original = [] | |
| for i, line in enumerate(all_lines): | |
| if line.strip() != '': | |
| non_empty_to_original.append(i) | |
| if not preserve_capitalization: | |
| texts = [line.lower() for line in texts] | |
| total_texts = len(texts) | |
| if total_texts == 0: | |
| return '\n'.join(all_lines) | |
| # across_lines=True logic | |
| line_punct_maps = None | |
| line_cap_maps = None | |
| stripped_lines = None | |
| lowered_lines = None | |
| if preserve_punctuation: | |
| line_punct_maps = [] | |
| stripped_lines = [] | |
| for line in texts: | |
| stripped_line, punct_map = self._extract_punctuation(line) | |
| stripped_lines.append(stripped_line) | |
| line_punct_maps.append(punct_map) | |
| if preserve_capitalization: | |
| line_cap_maps = [] | |
| lowered_lines = [] | |
| cap_source_lines = stripped_lines if stripped_lines is not None else texts | |
| for line in cap_source_lines: | |
| lowered_line, cap_map = self._extract_capitalization(line) | |
| lowered_lines.append(lowered_line) | |
| line_cap_maps.append(cap_map) | |
| if lowered_lines is not None: | |
| word_counts = [len(line.split()) for line in lowered_lines] | |
| full_text = ' '.join(lowered_lines) | |
| elif stripped_lines is not None: | |
| word_counts = [len(line.split()) for line in stripped_lines] | |
| full_text = ' '.join(stripped_lines) | |
| else: | |
| word_counts = [len(line.split()) for line in texts] | |
| full_text = ' '.join(texts) | |
| normalized_full = self.normalize_text( | |
| full_text, preserve_punctuation=False, | |
| preserve_capitalization=False, | |
| progress_callback=progress_callback, | |
| common_apocopes=False, # applied below on final result | |
| **kwargs | |
| ) | |
| num_lines = len(texts) | |
| normalized_words = normalized_full.split() | |
| total_norm = len(normalized_words) | |
| word_to_line = [] | |
| for line_idx, count in enumerate(word_counts): | |
| for _ in range(count): | |
| word_to_line.append(line_idx) | |
| total_orig = sum(word_counts) | |
| original_words = full_text.split() | |
| def normalize_word_for_tracking(word: str) -> str: | |
| base = word.lower().replace('ſ', 's') | |
| simplified = base | |
| for char, replacement in [ | |
| ('â', 'a'), ('î', 'i'), ('û', 'u'), ('ô', 'o'), | |
| ('ä', 'a'), ('ë', 'e'), ('ï', 'i'), ('ö', 'o'), ('ü', 'u'), | |
| ('é', 'e'), ('è', 'e'), ('ê', 'e'), ('à', 'a'), | |
| ]: | |
| simplified = simplified.replace(char, replacement) | |
| return simplified | |
| orig_occurrences = {} | |
| for i, word in enumerate(original_words): | |
| norm_word = normalize_word_for_tracking(word) | |
| if norm_word not in orig_occurrences: | |
| orig_occurrences[norm_word] = [] | |
| orig_occurrences[norm_word].append(i) | |
| # Progress: alignment is the slowest step on CPU (O(n*m) difflib) | |
| if progress_callback: | |
| progress_callback(78, 100) | |
| # Progress: about to start character-level alignment (slowest CPU step) | |
| if progress_callback: | |
| progress_callback(79, 100) | |
| norm_to_orig_word = self._align_normalized_to_original_charlevel( | |
| full_text, normalized_full | |
| ) | |
| # Progress: alignment done | |
| if progress_callback: | |
| progress_callback(84, 100) | |
| norm_to_orig_occurrence = norm_to_orig_word.copy() | |
| split_group = {} | |
| group_id = 0 | |
| i = 0 | |
| while i < total_norm: | |
| if i == total_norm - 1: | |
| split_group[i] = group_id | |
| group_id += 1 | |
| break | |
| # Only group consecutive normalized words that map to the *same* | |
| # original word (true word splits like "inalten" → "in alten"). | |
| # Do NOT group words mapping to adjacent originals — that collapses | |
| # normal line boundaries and piles words onto the first line. | |
| orig_i = norm_to_orig_word[i] | |
| orig_next = norm_to_orig_word[i + 1] | |
| if orig_i == orig_next: | |
| # Collect consecutive words mapping to this original | |
| split_words = [] | |
| j = i | |
| while j < total_norm and norm_to_orig_word[j] == orig_i: | |
| split_words.append(normalized_words[j]) | |
| j += 1 | |
| # Validate: true splits have combined length similar to original | |
| combined = ''.join(split_words) | |
| orig_word = original_words[orig_i] if orig_i < len(original_words) else '' | |
| is_true_split = ( | |
| len(split_words) > 1 and | |
| len(combined) >= len(orig_word) * 0.5 and | |
| len(combined) <= len(orig_word) * 1.5 | |
| ) | |
| if is_true_split: | |
| for k in range(i, j): | |
| split_group[k] = group_id | |
| group_id += 1 | |
| i = j | |
| else: | |
| # False split (repeated words) — each gets its own group | |
| split_group[i] = group_id | |
| group_id += 1 | |
| i += 1 | |
| else: | |
| split_group[i] = group_id | |
| group_id += 1 | |
| i += 1 | |
| # Progress: split grouping done, starting line assignment | |
| if progress_callback: | |
| progress_callback(85, 100) | |
| norm_to_line = self._assign_lines_by_word_alignment( | |
| lowered_lines if lowered_lines is not None else | |
| (stripped_lines if stripped_lines is not None else texts), | |
| normalized_full | |
| ) | |
| # Progress: line assignment done | |
| if progress_callback: | |
| progress_callback(88, 100) | |
| line_word_lists = [[] for _ in range(num_lines)] | |
| for norm_idx in range(total_norm): | |
| target_line = norm_to_line[norm_idx] | |
| line_word_lists[target_line].append(normalized_words[norm_idx]) | |
| # Progress: word distribution done | |
| if progress_callback: | |
| progress_callback(90, 100) | |
| empty_lines = [i for i in range(num_lines) if not line_word_lists[i]] | |
| if empty_lines: | |
| for empty_idx in empty_lines: | |
| best_source = None | |
| best_dist = float('inf') | |
| for src_idx, words in enumerate(line_word_lists): | |
| if len(words) > 1: | |
| dist = abs(src_idx - empty_idx) | |
| if dist < best_dist: | |
| best_dist = dist | |
| best_source = src_idx | |
| if best_source is not None: | |
| word = line_word_lists[best_source].pop() | |
| line_word_lists[empty_idx].append(word) | |
| # Progress: starting capitalization/punctuation restoration loop | |
| if progress_callback: | |
| progress_callback(91, 100) | |
| normalized = [] | |
| for line_idx in range(num_lines): | |
| line_text = ' '.join(line_word_lists[line_idx]) | |
| if preserve_capitalization and line_cap_maps is not None and line_idx < len(line_cap_maps): | |
| cap_ref = lowered_lines[line_idx] if lowered_lines is not None else None | |
| if cap_ref is not None: | |
| line_text = self._restore_capitalization_aligned( | |
| cap_ref, line_text, line_cap_maps[line_idx] | |
| ) | |
| if preserve_punctuation and line_punct_maps is not None and line_idx < len(line_punct_maps): | |
| punct_ref = stripped_lines[line_idx] if stripped_lines is not None else None | |
| if punct_ref is not None: | |
| line_text = self._restore_punctuation_aligned( | |
| punct_ref, line_text, line_punct_maps[line_idx] | |
| ) | |
| normalized.append(line_text) | |
| # Progress: restoration loop done | |
| if progress_callback: | |
| progress_callback(93, 100) | |
| while len(normalized) < total_texts: | |
| normalized.append('') | |
| # Reinsert empty lines | |
| total_original_lines = len(all_lines) | |
| if empty_line_indices: | |
| full_output = [''] * total_original_lines | |
| for norm_idx, orig_idx in enumerate(non_empty_to_original): | |
| if norm_idx < len(normalized) and orig_idx < total_original_lines: | |
| full_output[orig_idx] = normalized[norm_idx] | |
| for norm_idx in range(len(non_empty_to_original), len(normalized)): | |
| full_output.append(normalized[norm_idx]) | |
| output_lines = full_output | |
| else: | |
| output_lines = normalized | |
| # Progress: empty line reinsertion done | |
| if progress_callback: | |
| progress_callback(95, 100) | |
| result = '\n'.join(output_lines) | |
| # Progress: join done, starting post-processing regex | |
| if progress_callback: | |
| progress_callback(96, 100) | |
| # Post-processing: common apocopes (before en-attachment so "en wile" → "en wil" → "enwil") | |
| if common_apocopes: | |
| result = _common_apocopes(result) | |
| # Post-processing: attach negation proclitic (after apocopes) | |
| if attach_en_proclitic: | |
| result = _attach_en_proclitic(result) | |
| # Post-processing: lenition of t after n before vowel | |
| if lenition_t_after_n: | |
| result = _lenition_t_after_n_before_vowel(result) | |
| # Post-processing: lenition of t after l before vowel | |
| if lenition_t_after_l: | |
| result = _lenition_t_after_l_before_vowel(result) | |
| # Post-processing: niet → niht | |
| if niet_to_niht: | |
| result = _niet_to_niht(result) | |
| # Progress: all done | |
| if progress_callback: | |
| progress_callback(100, 100) | |
| return result | |
| def normalize_and_compare( | |
| self, | |
| texts: List[str], | |
| reference_texts: Optional[List[str]] = None, | |
| num_samples: int = 5, | |
| **kwargs | |
| ): | |
| """ | |
| Normalize texts and optionally compare with references. | |
| Args: | |
| texts: List of input texts | |
| reference_texts: Optional list of reference normalized texts | |
| num_samples: Number of samples to display | |
| **kwargs: Additional arguments for normalize_text | |
| """ | |
| print(f"\n{'='*60}") | |
| print(f"Normalizing {len(texts)} texts") | |
| print(f"{'='*60}\n") | |
| normalized = self.normalize_texts(texts, **kwargs) | |
| # Print samples | |
| num_samples = min(num_samples, len(texts)) | |
| for i in range(num_samples): | |
| print(f"Sample {i + 1}:") | |
| print(f" Original: {texts[i]}") | |
| print(f" Normalized: {normalized[i]}") | |
| if reference_texts: | |
| print(f" Reference: {reference_texts[i]}") | |
| match = normalized[i] == reference_texts[i] | |
| print(f" Match: {match}") | |
| print() | |
| return normalized | |
| def main(): | |
| parser = argparse.ArgumentParser(description='Normalize Middle High German text') | |
| # Model arguments | |
| parser.add_argument('--model', type=str, | |
| default='/home/jonas/normaere/checkpoints/best_model', | |
| help='Path to trained model') | |
| parser.add_argument('--config', type=str, default='/home/jonas/normaere/config.yaml', | |
| help='Path to configuration file') | |
| # Input arguments | |
| parser.add_argument('--text', type=str, help='Single text to normalize') | |
| parser.add_argument('--input_file', type=str, help='Input file with texts (one per line)') | |
| parser.add_argument('--output_file', type=str, help='Output file for normalized texts') | |
| # Generation arguments | |
| parser.add_argument('--max_length', type=int, default=512, | |
| help='Maximum generation length') | |
| parser.add_argument('--num_beams', type=int, default=5, | |
| help='Number of beams') | |
| parser.add_argument('--length_penalty', type=float, default=0.6, | |
| help='Length penalty') | |
| parser.add_argument('--do_sample', action='store_true', | |
| help='Use sampling instead of beam search') | |
| parser.add_argument('--top_p', type=float, default=None, | |
| help='Nucleus sampling threshold (0.0 to 1.0)') | |
| parser.add_argument('--temperature', type=float, default=None, | |
| help='Temperature for sampling (0.1 to 2.0, lower = more deterministic, higher = more random)') | |
| parser.add_argument('--batch_size', type=int, default=8, | |
| help='Batch size for file processing') | |
| # Stride arguments | |
| parser.add_argument('--stride', action='store_true', default=True, | |
| help='Use sliding window stride for long texts (default: True)') | |
| parser.add_argument('--no-stride', action='store_false', dest='stride', | |
| help='Disable stride; truncate long texts instead') | |
| parser.add_argument('--stride_window_tokens', type=int, default=None, | |
| help='Max input tokens per stride window (default: 400)') | |
| parser.add_argument('--stride_step_tokens', type=int, default=None, | |
| help='Token advance between windows; overlap = window - step (default: 300)') | |
| # Across-lines arguments | |
| parser.add_argument('--across-lines', action='store_true', default=True, | |
| help='Join all lines into one continuous text for normalization, ' | |
| 'utilizing full model capacity and stride across line boundaries ' | |
| '(default: True)') | |
| parser.add_argument('--no-across-lines', action='store_false', dest='across_lines', | |
| help='Normalize each line independently (original behavior)') | |
| # Punctuation preservation arguments | |
| parser.add_argument('--preserve-punctuation', action='store_true', default=True, | |
| help='Strip editorial punctuation before normalization and ' | |
| 'restore it afterwards (default: True)') | |
| parser.add_argument('--no-preserve-punctuation', action='store_false', dest='preserve_punctuation', | |
| help='Do not strip/restore punctuation; pass text as-is to the model') | |
| # Capitalization preservation arguments | |
| parser.add_argument('--preserve-capitalization', action='store_true', default=True, | |
| help='Record capitalization before normalization (lowercasing input) ' | |
| 'and restore it afterwards (default: True)') | |
| parser.add_argument('--no-preserve-capitalization', action='store_false', dest='preserve_capitalization', | |
| help='Do not preserve capitalization; input is lowercased without restoration') | |
| args = parser.parse_args() | |
| # Initialize normalizer | |
| normalizer = MHGNormalizer(args.model, args.config) | |
| # Build stride kwargs | |
| stride_kwargs = { | |
| 'stride': args.stride, | |
| 'stride_window_tokens': args.stride_window_tokens, | |
| 'stride_step_tokens': args.stride_step_tokens, | |
| } | |
| if args.text: | |
| # Normalize single text | |
| print("\nOriginal text:") | |
| print(args.text) | |
| print("\nNormalized text:") | |
| normalized = normalizer.normalize_text( | |
| args.text, | |
| max_length=args.max_length, | |
| num_beams=args.num_beams, | |
| length_penalty=args.length_penalty, | |
| do_sample=args.do_sample, | |
| top_p=args.top_p, | |
| temperature=args.temperature, | |
| preserve_punctuation=args.preserve_punctuation, | |
| preserve_capitalization=args.preserve_capitalization, | |
| **stride_kwargs | |
| ) | |
| print(normalized) | |
| print() | |
| elif args.input_file: | |
| # Normalize file | |
| if not args.output_file: | |
| # Create output path based on input | |
| input_path = Path(args.input_file) | |
| args.output_file = input_path.parent / f"{input_path.stem}_normalized{input_path.suffix}" | |
| normalizer.normalize_file( | |
| args.input_file, | |
| args.output_file, | |
| batch_size=args.batch_size, | |
| across_lines=args.across_lines, | |
| preserve_punctuation=args.preserve_punctuation, | |
| preserve_capitalization=args.preserve_capitalization, | |
| max_length=args.max_length, | |
| num_beams=args.num_beams, | |
| length_penalty=args.length_penalty, | |
| do_sample=args.do_sample, | |
| top_p=args.top_p, | |
| temperature=args.temperature, | |
| **stride_kwargs | |
| ) | |
| else: | |
| # Interactive mode | |
| print("\nInteractive mode. Enter MHG text to normalize (Ctrl+D to exit):") | |
| print("="*60 + "\n") | |
| try: | |
| texts = [] | |
| while True: | |
| text = input("> ") | |
| if text.strip(): | |
| texts.append(text) | |
| except EOFError: | |
| pass | |
| if texts: | |
| print(f"\nNormalizing {len(texts)} texts...\n") | |
| normalized = normalizer.normalize_texts( | |
| texts, | |
| preserve_punctuation=args.preserve_punctuation, | |
| preserve_capitalization=args.preserve_capitalization, | |
| **stride_kwargs | |
| ) | |
| for i, (orig, norm) in enumerate(zip(texts, normalized)): | |
| print(f"\nText {i + 1}:") | |
| print(f" Original: {orig}") | |
| print(f" Normalized: {norm}") | |
| if __name__ == '__main__': | |
| main() | |