Files
wehub-resource-sync ba4be087d5
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-test-container-build (push) Has been cancelled
CICD NeMo / cicd-import-tests (push) Has been cancelled
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Has been cancelled
CICD NeMo / cicd-main-speech (push) Has been cancelled
CICD NeMo / Nemo_CICD_Test (push) Has been cancelled
CICD NeMo / Coverage (e2e) (push) Has been cancelled
CICD NeMo / Coverage (unit-test) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CICD NeMo / cicd-wait-in-queue (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:58 +08:00

671 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
A rule-based text cleaning script for preparing text for ASR-EOU model training.
Example usage:
```bash
python clean_manifest.py \
/path/to/manifest/dir \
-o /path/to/output/dir
```
"""
import argparse
import re
import unicodedata
from datetime import datetime
from pathlib import Path
from string import punctuation
from num2words import num2words
from whisper_normalizer.english import EnglishTextNormalizer
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
punctuations = punctuation.replace("'", "")
text_normalizer = EnglishTextNormalizer()
parser = argparse.ArgumentParser(description="Clean manifest file")
parser.add_argument(
"input_manifest",
type=str,
help="Path to the input manifest file to be cleaned.",
)
parser.add_argument(
"-o",
"--output",
type=str,
default=None,
help="Path to the output manifest file after cleaning.",
)
parser.add_argument(
"-lower",
"--lowercase",
type=bool,
default=False,
help="Whether to convert the text to lowercase.",
)
parser.add_argument(
"-drop",
"--remove_punc",
type=bool,
default=False,
help="Whether to remove punctuation from the text.",
)
parser.add_argument(
"--normalize",
type=bool,
default=False,
help="Whether to normalize the text using Whisper text normalizer.",
)
parser.add_argument(
"-n2w",
"--replace_numbers",
type=bool,
default=True,
help="Whether to replace numbers with words.",
)
parser.add_argument(
"-p",
"--pattern",
type=str,
default="**/*.json",
help="Pattern to match files in the input directory.",
)
parser.add_argument(
"-t",
"--text_field",
type=str,
default="text",
help="Field in the manifest to clean. Default is 'text'.",
)
parser.add_argument(
"--auto_pc",
action="store_true",
help="If set, will add auto capitalization and punctuation at the end of the text.",
)
parser.add_argument(
"--format",
default="asr",
choices=["asr", "conv"],
help="Format of the manifest. Default is 'asr'.",
)
parser.add_argument(
"--keep_name",
action="store_true",
help="If set, will keep the original name of the manifest file.",
)
# Spoken representations
MONTHS = [
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
ORDINALS = {
1: "first",
2: "second",
3: "third",
4: "fourth",
5: "fifth",
6: "sixth",
7: "seventh",
8: "eighth",
9: "ninth",
10: "tenth",
11: "eleventh",
12: "twelfth",
13: "thirteenth",
14: "fourteenth",
15: "fifteenth",
16: "sixteenth",
17: "seventeenth",
18: "eighteenth",
19: "nineteenth",
20: "twentieth",
21: "twenty first",
22: "twenty second",
23: "twenty third",
24: "twenty fourth",
25: "twenty fifth",
26: "twenty sixth",
27: "twenty seventh",
28: "twenty eighth",
29: "twenty ninth",
30: "thirtieth",
31: "thirty first",
}
def speak_year(year: int) -> str:
if 2000 <= year <= 2099:
return f"twenty {speak_number(year % 100)}"
elif 1900 <= year <= 1999:
return f"nineteen {speak_number(year % 100)}"
else:
return str(year)
def speak_number(n: int) -> str:
num_words = {
0: "zero",
1: "one",
2: "two",
3: "three",
4: "four",
5: "five",
6: "six",
7: "seven",
8: "eight",
9: "nine",
10: "ten",
11: "eleven",
12: "twelve",
13: "thirteen",
14: "fourteen",
15: "fifteen",
16: "sixteen",
17: "seventeen",
18: "eighteen",
19: "nineteen",
20: "twenty",
30: "thirty",
40: "forty",
50: "fifty",
60: "sixty",
70: "seventy",
80: "eighty",
90: "ninety",
}
if n <= 20:
return num_words[n]
elif n < 100:
tens, ones = divmod(n, 10)
return f"{num_words[tens * 10]} {num_words[ones]}" if ones else num_words[tens * 10]
else:
return str(n)
def _parse_numeric_date(date_str: str, *, dayfirst: bool):
separator = "/" if "/" in date_str else "-"
parts = date_str.split(separator)
if len(parts) == 3 and len(parts[0]) == 4:
year, month, day = [int(part) for part in parts]
elif len(parts) == 3:
first, second, year = [int(part) for part in parts]
day, month = (first, second) if dayfirst else (second, first)
elif len(parts) == 2:
first, second = [int(part) for part in parts]
year = datetime.now().year
day, month = (first, second) if dayfirst else (second, first)
else:
raise ValueError(date_str)
if year < 100:
year += 2000 if year < 69 else 1900
return datetime(year, month, day)
def parse_with_auto_dayfirst(date_str: str):
try:
parsed_us = _parse_numeric_date(date_str, dayfirst=False)
except Exception:
parsed_us = None
try:
parsed_eu = _parse_numeric_date(date_str, dayfirst=True)
except Exception:
parsed_eu = None
if parsed_us is None:
return parsed_eu
if parsed_eu is None:
return parsed_us
first = int(date_str.split("/" if "/" in date_str else "-")[0])
if first > 12 and len(str(first)) != 4:
return parsed_eu
# Default fallback assumes US style for ambiguous numeric dates.
return parsed_us
def date_to_spoken_string(date_str: str) -> str:
parsed = parse_with_auto_dayfirst(date_str)
if not parsed:
return None
month = MONTHS[parsed.month]
day = ORDINALS[parsed.day]
spoken = f"{month} {day} {speak_year(parsed.year)}"
return spoken
def replace_dates_in_text(text: str) -> str:
# Regex pattern to match common date formats like:
# 5/22, 05/22/2025, 22/05/2025, 2025-05-22
date_pattern = r'\b(?:\d{1,4}[-/])?\d{1,2}[-/]\d{1,4}\b'
def replace_match(match):
date_str = match.group(0)
spoken = date_to_spoken_string(date_str)
return spoken if spoken else date_str
return re.sub(date_pattern, replace_match, text)
def convert_to_spoken(text: str) -> str:
text = replace_dates_in_text(text) # Convert dates to spoken form
# Mapping of metric units to spoken forms
unit_map = {
"kg": "kilograms",
"g": "grams",
"mg": "milligrams",
"l": "liters",
"ml": "milliliters",
"cm": "centimeters",
"mm": "millimeters",
"m": "meters",
"km": "kilometers",
"°c": "degrees celsius",
"°f": "degrees fahrenheit",
"oz": "ounces",
"lb": "pounds",
"lbs": "pounds",
}
# Replace metric units like "12kg" or "5 ml"
def replace_metric(match):
number = match.group(1)
unit = match.group(2).lower()
spoken_unit = unit_map.get(unit, unit)
return f"{number} {spoken_unit}"
# Replace time like "5am" or "6PM"
def replace_ampm(match):
hour = match.group(1)
meridiem = match.group(2).lower()
return f"{hour} {'a m' if meridiem == 'am' else 'p m'}"
# Replace time like "1:30pm"
def replace_colon_time(match):
hour = match.group(1)
minute = match.group(2)
meridiem = match.group(3).lower()
return f"{hour} {minute} {'a m' if meridiem == 'am' else 'p m'}"
# Convert feet and inches like 5'11" to "5 feet 11 inches"
def replace_feet_inches(match):
feet = match.group(1)
inches = match.group(2)
return f"{feet} feet {inches} inches"
# Convert just feet (e.g., 6') to "6 feet"
def replace_feet_only(match):
feet = match.group(1)
return f"{feet} feet"
# Convert just inches (e.g., 10") to "10 inches"
def replace_inches_only(match):
inches = match.group(1)
return f"{inches} inches"
# Apply replacements
# First: time with colon (e.g., 1:30pm)
text = re.sub(r'\b(\d{1,2}):(\d{2})(am|pm)\b', replace_colon_time, text, flags=re.IGNORECASE)
# Then: basic am/pm (e.g., 5am)
text = re.sub(r'\b(\d{1,2})(am|pm)\b', replace_ampm, text, flags=re.IGNORECASE)
# Then: replace 1st, 2nd, 3rd, etc
text = text.replace("1st", "first")
text = text.replace("2nd", "second")
text = text.replace("3rd", "third")
text = text.replace("@", " at ")
# Finally: metric units
text = re.sub(
r'\b(\d+(?:\.\d+)?)\s?(kg|g|mg|l|ml|cm|mm|m|km|°c|°f|oz|lbs?|LB|LBS?)\b',
replace_metric,
text,
flags=re.IGNORECASE,
)
text = re.sub(r'\b(\d+)\'(\d+)"', replace_feet_inches, text) # e.g., 5'11"
text = re.sub(r'\b(\d+)\'', replace_feet_only, text) # e.g., 6'
text = re.sub(r'(\d+)"', replace_inches_only, text) # e.g., 10"
return text
def replace_numbers_with_words(text):
def convert_number(match):
num_str = match.group()
original = num_str
# Remove dollar sign
is_dollar = False
if num_str.startswith('$'):
is_dollar = True
num_str = num_str[1:]
elif num_str.endswith('$'):
is_dollar = True
num_str = num_str[:-1]
# Remove commas
num_str = num_str.replace(',', '')
try:
if '.' in num_str:
# Convert decimal number
integer_part, decimal_part = num_str.split('.')
words = num2words(int(integer_part)) + ' point ' + ' '.join(num2words(int(d)) for d in decimal_part)
else:
words = num2words(int(num_str))
if is_dollar:
words += ' dollars'
return words + " "
except Exception:
return original # Return original if conversion fails
# Pattern matches: $3,000 or 3,000.45 or 1234
pattern = re.compile(r'\$?\d{1,3}(?:,\d{3})*(?:\.\d+)?|\$?\d+(?:\.\d+)?')
result = pattern.sub(convert_number, text)
result = result.replace("$", " dollars ") # Handle dollar sign separately
def merge_th(text: str) -> str:
# merge th with the preceding digit
candidates = ["four th ", "five th ", "six th ", "seven th ", "eight th ", "nine th "]
for key in candidates:
if key in text:
if "five" in key:
target = "fifth "
else:
target = f"{key.split(' ')[0]}th "
text = text.replace(key, target)
elif text.endswith(key.strip()):
if "five" in key:
target = "fifth"
else:
target = f"{key.split(' ')[0]}th"
text = text.replace(key.strip(), target)
return text
result = merge_th(result)
result = " ".join(result.split()) # Remove extra spaces
return result
def unicode_to_ascii(text: str) -> str:
"""
Converts text with accented or special Latin characters (e.g., ó, ñ, ū, ō)
into their closest ASCII equivalents.
"""
# Normalize the string to NFKD to separate base characters from diacritics
normalized = unicodedata.normalize('NFKD', text)
# Encode to ASCII bytes, ignoring characters that can't be converted
ascii_bytes = normalized.encode('ascii', 'ignore')
# Decode back to string
ascii_text = ascii_bytes.decode('ascii')
return ascii_text
def drop_punctuations(text: str) -> str:
"""
Clean the text by removing invalid characters and converting to lowercase.
:param text: Input text.
:return: Cleaned text.
"""
valid_chars = "abcdefghijklmnopqrstuvwxyz'"
text = text.lower()
text = unicode_to_ascii(text)
text = text.replace(":", " ")
text = text.replace("-", " ")
text = ''.join([c for c in text if c in valid_chars or c.isspace()])
text = ' '.join(text.split()).strip()
return text
def clean_label(_str: str) -> str:
"""
Remove unauthorized characters in a string, lower it and remove unneeded spaces
"""
# replace_with_space = [char for char in '/?*\",.:=?_{|}~¨«·»¡¿„…‧‹›≪≫!:;ː→']
replace_with_blank = [char for char in '`¨´‘’“”`ʻ‘’“"‘”']
replace_with_apos = [char for char in '‘’ʻ‘’‘'] + ["\u2019"]
_str = _str.strip()
for i in replace_with_blank:
_str = _str.replace(i, "")
for i in replace_with_apos:
_str = _str.replace(i, "'")
text = _str
text = text.replace("\u2103", "celsius")
text = text.replace("\u2109", "fahrenheit")
text = text.replace("\u00b0", "degrees")
text = text.replace("\u2019", "'")
text = text.replace("\\", ".")
text = text.replace("\n", " ")
text = text.replace("\r", " ")
text = text.replace("\t", " ")
ret = " ".join(text.split())
return ret
def ends_with_punctuation(s: str) -> bool:
# Strip trailing whitespace
s = s.rstrip()
# consider this set to be punctuation that's acceptable to end a sentence with
puncturation_chars = [",", ".", ":", ";", "?", "!", "-", "—", "", "…"]
# If string is empty after stripping, return False
if not s:
return False
# Get the last character
last_char = s[-1]
# Return True if the last character is punctuation, otherwise False
return last_char in puncturation_chars
def add_period_if_needed(text: str) -> str:
"""
Add a period at the end of the text if it does not already end with one.
"""
if not ends_with_punctuation(text):
text += "."
return text.strip()
def capitalize_self_i(text):
# Replace standalone lowercase "i" with "I"
# Handles "i", "i.", "i?", "i'll", "i'm", etc.
return re.sub(r'\b(i)(?=[\s.,!?;:\'\"-]|$)', r'I', text)
def add_space_after_punctuation(text):
# Add a space after punctuation if it's not already followed by one or by the end of the string
return re.sub(r'([,\.?;:])(?=\S)', r'\1 ', text)
def add_auto_capitalization(text):
if text.lower() != text:
# If the text is not all lowercase, we assume it has some capitalization
return text
# Remove space before punctuation (.,!?;:)
text = re.sub(r'\s+([.,!?;:])', r'\1', text)
# Capitalize the first letter of each sentence
def capitalize_sentences(match):
return match.group(1) + match.group(2).upper()
# Ensure first character is capitalized
text = text.strip()
if text:
text = text[0].upper() + text[1:]
text = capitalize_self_i(text)
text = add_space_after_punctuation(text)
# Capitalize after sentence-ending punctuation followed by space(s)
text = re.sub(r'([.!?]\s+)([a-z])', capitalize_sentences, text)
return text
def unicode_to_ascii(text: str) -> str:
"""
Converts text with accented or special Latin characters (e.g., ó, ñ, ū, ō)
into their closest ASCII equivalents.
"""
# Normalize the string to NFKD to separate base characters from diacritics
normalized = unicodedata.normalize('NFKD', text)
# Encode to ASCII bytes, ignoring characters that can't be converted
ascii_bytes = normalized.encode('ascii', 'ignore')
# Decode back to string
ascii_text = ascii_bytes.decode('ascii')
return ascii_text
def clean_text(text: str, args) -> str:
"""
Clean the text based on the provided arguments.
"""
text = unicode_to_ascii(text)
if args.normalize:
text = text_normalizer(text)
if args.replace_numbers:
text = convert_to_spoken(text)
text = replace_numbers_with_words(text)
if args.lowercase:
text = text.lower()
if args.remove_punc:
text = text.replace("-", " ")
text = text.replace("_", " ")
text = text.translate(str.maketrans("", "", punctuations))
text = drop_punctuations(text)
if args.auto_pc:
text = add_auto_capitalization(text)
return clean_label(text)
def clean_asr_manifest(manifest, text_field, args):
for i, item in enumerate(manifest):
text = str(item[text_field])
manifest[i][f"origin_{text_field}"] = text
manifest[i][text_field] = clean_text(text, args)
return manifest
def clean_conv_manifest(manifest, text_field, args):
new_manifest = []
for i, item in enumerate(manifest):
conversations = []
for turn in item["conversations"]:
conversations.append(
{
"role": turn["role"],
"value": clean_text(turn["value"], args),
"type": turn.get("type", "text"),
}
)
item["conversations"] = conversations
new_manifest.append(item)
return manifest
def main(args):
text_field = args.text_field
manifest_files = Path(args.input_manifest)
if manifest_files.is_dir():
manifest_files = list(manifest_files.glob(args.pattern))
elif manifest_files.is_file():
manifest_files = [manifest_files]
else:
raise ValueError(f"Invalid input manifest path: {args.input_manifest}")
for manifest_file in manifest_files:
print(f"Processing manifest file: {manifest_file}")
postfix = "-cleaned"
postfix += "_norm" if args.normalize else ""
postfix += "_n2w" if args.replace_numbers else ""
if args.lowercase and args.remove_punc:
postfix += "_noPC"
else:
postfix += "_lc" if args.lowercase else ""
postfix += "_np" if args.remove_punc else ""
postfix += "_aPC" if args.auto_pc else ""
output_manifest = manifest_file.with_name(f"{manifest_file.stem}{postfix}{manifest_file.suffix}")
if args.output:
if args.output.endswith(".json"):
if len(manifest_files) > 1:
raise ValueError("Output path must be a directory when processing multiple manifest files.")
output_manifest = Path(args.output)
else:
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
if args.keep_name:
output_manifest = output_dir / manifest_file.name
else:
output_manifest = output_dir / output_manifest.name
manifest = read_manifest(str(manifest_file))
if args.format == "asr":
manifest = clean_asr_manifest(manifest, text_field, args)
elif args.format == "conv":
manifest = clean_conv_manifest(manifest, text_field, args)
else:
raise ValueError(f"Unsupported manifest format: {args.format}")
write_manifest(str(output_manifest), manifest)
print(f"Cleaned manifest saved to {output_manifest}")
if __name__ == "__main__":
args = parser.parse_args()
main(args)