Abdelrahman2922's picture
Add files using upload-large-folder tool
8d6afae verified
Raw
History Blame Contribute Delete
16.8 kB
#!/usr/bin/env python3
# Copyright 2026 Xiaomi Corp. (authors: Han Zhu)
#
# See ../../LICENSE for clarification regarding multiple authors
#
# 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.
"""Computes word error rate (WER) for FLEURS multilingual evaluation.
Uses omnilingual-asr for ASR transcription across 100+ languages.
Requires a separate environment with ``omnilingual_asr`` installed.
Usage:
python3 omnivoice/eval/wer/fleurs.py \\
--wav-path results/fleurs \\
--test-list test.jsonl \\
--decode-path results/fleurs.wer.log \\
--model-card omniASR_LLM_Unlimited_7B_v2 \\
--chunk-size 100 --batch-size 50
"""
import argparse
import logging
import multiprocessing as mp
import os
import re
import sys
import traceback
import types
from collections import defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import List, Union
import numpy as np
import torch
from tqdm import tqdm
try:
from omnilingual_asr.models.inference.pipeline import ASRInferencePipeline
from omnilingual_asr.models.wav2vec2_llama.lang_ids import supported_langs
except ImportError:
logging.error("Please install omnilingual_asr first.")
exit(1)
# omnilingual-asr may pull a transformers version that lacks
# HiggsAudioV2TokenizerModel. Pre-register stubs to bypass
# omnivoice/__init__.py heavy imports.
if "omnivoice" not in sys.modules:
_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
for _name in (
"omnivoice",
"omnivoice.eval",
"omnivoice.eval.wer",
"omnivoice.utils",
):
if _name not in sys.modules:
_m = types.ModuleType(_name)
_m.__path__ = [os.path.join(_root, *_name.split(".")[1:])]
_m.__package__ = _name
sys.modules[_name] = _m
from omnivoice.eval.wer.common import log_metrics, process_one
from omnivoice.eval.wer.text_norm_omni import text_normalize
from omnivoice.utils.data_utils import read_test_list
# --- Global variables for worker processes ---
worker_pipe = None
worker_device = None
# fix mismatched language codes between OmniVoice and Omnilingual-ASR model
rename = {
"et": "ekk",
"ms": "zsm",
"sw": "swh",
"npi": "nep",
}
def read_language_mapping_from_tsv(
mapping_path: Path,
) -> dict[str, Union[str, List[str]]]:
with open(mapping_path, "r", encoding="utf-8") as f:
_ = f.readline() # Skip header
language_mapping = {}
for line in f:
parts = line.strip().split("\t")
mixed_id, language_name, iso_639_3_id, duration = parts
language_mapping[iso_639_3_id] = mixed_id
return language_mapping
iso_639_3_id_to_mixed_id = read_language_mapping_from_tsv(
Path(f"{os.path.dirname(__file__)}/../../../docs/lang_id_name_map.tsv")
)
mixed_id_to_omnilingual_asr_lang = {}
for lang in supported_langs:
if lang in ("cmn_Hant",):
continue
iso_639_3_lang_code = lang.split("_")[0]
if iso_639_3_lang_code in iso_639_3_id_to_mixed_id:
mixed_id = iso_639_3_id_to_mixed_id[iso_639_3_lang_code]
mixed_id_to_omnilingual_asr_lang[mixed_id] = lang
else:
mixed_id_to_omnilingual_asr_lang[iso_639_3_lang_code] = lang
def clean_cjk_spaces(text):
"""
Removes spaces adjacent to Chinese and Japanese characters while preserving
meaningful spaces in English or other languages (like Korean).
"""
# Define CJK (Chinese, Japanese) Unicode ranges
# \u4e00-\u9fff: CJK Unified Ideographs (Chinese)
# \u3040-\u309f: Hiragana (Japanese)
# \u30a0-\u30ff: Katakana (Japanese)
# \u3000-\u303f: CJK Symbols and Punctuation
cjk_range = r"\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\u3000-\u303f"
# 1. Remove spaces between two CJK characters
# Example: "我 爱 你" -> "我爱你"
text = re.sub(f"([{cjk_range}])\\s+([{cjk_range}])", r"\1\2", text)
# 2. Remove spaces between a CJK character and a non-CJK character (English/Numbers)
# Example: "我 爱 you" -> "我爱you"
text = re.sub(f"([{cjk_range}])\\s+", r"\1", text)
text = re.sub(f"\\s+([{cjk_range}])", r"\1", text)
# 3. Collapse multiple spaces into one for the remaining parts (e.g., English words)
text = re.sub(r"\s+", " ", text)
return text.strip()
def get_parser():
parser = argparse.ArgumentParser(
description="Computes WER with Whisper.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--wav-path",
type=str,
required=True,
help="Path to the directory containing speech files.",
)
parser.add_argument(
"--extension",
type=str,
default="wav",
help="Extension of the speech files. Default: wav",
)
parser.add_argument(
"--decode-path",
type=str,
default=None,
help="Path to the output file where WER information will be saved. "
"If not provided, results are only printed to console.",
)
parser.add_argument(
"--model-card",
type=str,
default="omniASR_LLM_7B",
help="Model card name for OmniASR (e.g., omniASR_LLM_7B) or local path.",
)
parser.add_argument(
"--test-list",
type=str,
default="test.jsonl",
help="path of the JSONL test list. Each line is a JSON object "
"with fields: id, text, ref_audio, ref_text, language_id, language_name.",
)
parser.add_argument(
"--lang",
type=str,
default=None,
help="""Language code to evaluate (e.g., 'en' for English, 'zh' for Chinese).
If not provided, the script will evaluate all languages found in the test list.
If specified, only samples of the given language will be evaluated.
""",
)
parser.add_argument(
"--batch-size",
type=int,
default=8,
help="Batch size for decoding with the Hugging Face pipeline.",
)
parser.add_argument(
"--nj-per-gpu", type=int, default=1, help="Number of workers per GPU."
)
parser.add_argument(
"--chunk-size",
type=int,
default=300,
help="Number of samples per task chunk sent to workers.",
)
return parser
def load_omni_model(model_card, device):
logging.info(f"Loading OmniASR model ({model_card}) on {device}...")
try:
pipeline = ASRInferencePipeline(model_card=model_card, device=str(device))
return pipeline
except Exception as e:
logging.error(f"Failed to load OmniASR pipeline: {e}")
return None
def process_init(rank_queue, model_card):
"""
Initializer for each worker process.
"""
global worker_pipe, worker_device
# Configure threads constraint
torch.set_num_threads(2)
try:
rank = rank_queue.get(timeout=10)
except Exception:
raise RuntimeError("Failed to get GPU rank from queue.")
assert torch.cuda.is_available(), "CUDA is required but not available."
worker_device = torch.device(f"cuda:{rank}")
torch.cuda.set_device(rank)
logging.info(f"Initializing worker on device: {worker_device}")
try:
# Using the model_card argument
worker_pipe = load_omni_model(model_card, worker_device)
if worker_pipe is None:
raise RuntimeError("Model loading failed.")
except Exception as e:
logging.critical(f"Failed to load model on {worker_device}: {e}")
raise e
def post_process(text: str, lang: str) -> str:
"""
Cleans and normalizes text for WER calculation.
Args:
text (str): The input text to be processed.
lang (str): The language of the input text.
Returns:
str: The cleaned and normalized text.
"""
lang_id = lang[:3] # Extract ISO 639-3 code (e.g., 'eng' from 'eng_Latn')
text = text_normalize(
text,
iso_code=lang_id,
lower_case=True,
remove_numbers=False,
remove_brackets=False,
)
text = clean_cjk_spaces(text)
text = text.replace(" ", "|")
text = " ".join([x for x in text])
return text
def run_eval_worker(data_chunk, language, batch_size):
"""
Worker function to process a chunk of data.
Uses the global worker_pipe initialized by process_init.
"""
global worker_pipe
if worker_pipe is None:
logging.error("Worker pipeline is not initialized!")
return []
metrics_buffer = []
try:
# Prepare batch lists for OmniASR
audio_paths = [item["wav_path"] for item in data_chunk]
# OmniASR expects explicit language codes for each file if not auto-detected.
# Using the language passed to the worker function, or item specific language
# Assuming item['lang_id'] is compatible (e.g., 'en', 'zh', 'arb_Arab')
# If the model needs full tokens like 'en_Latn', conversion might be needed here depending on input data.
lang_list = [item.get("lang_id", language) for item in data_chunk]
# Use the pipeline to infer batch
# OmniASR pipeline.transcribe returns a list of strings
transcriptions = worker_pipe.transcribe(
audio_paths, lang=lang_list, batch_size=batch_size
)
for i, hypo_text in enumerate(transcriptions):
ref_item = data_chunk[i]
truth = ref_item["truth_text"]
wav_path = ref_item["wav_path"]
lang_id = ref_item.get("lang_id")
lang_name = ref_item.get("lang_name")
m = process_one(hypo_text, truth, post_process, lang_id)
m["wav_path"] = wav_path
m["lang_name"] = lang_name
metrics_buffer.append(m)
except Exception:
logging.error(
f"Worker failed on chunk (Lang: {language}):\n{traceback.format_exc()}"
)
return []
return metrics_buffer
def main():
parser = get_parser()
args = parser.parse_args()
logging.basicConfig(
format="%(asctime)s %(levelname)s [%(filename)s:%(lineno)d] %(message)s",
level=logging.INFO,
force=True,
)
# 1. Prepare Data
logging.info("Reading test list...")
data_by_lang = defaultdict(list)
total_files = 0
wav_root = Path(args.wav_path)
samples = read_test_list(args.test_list)
for s in samples:
wav_path = str(wav_root / f"{s['id']}.{args.extension}")
if not os.path.exists(wav_path):
logging.warning(f"File missing: {wav_path}")
continue
lang_id = s.get("language_id") or "unknown"
if lang_id in rename:
lang_id = mixed_id_to_omnilingual_asr_lang[rename[lang_id]]
else:
lang_id = mixed_id_to_omnilingual_asr_lang[lang_id]
item = {
"wav_path": wav_path,
"truth_text": s["text"],
"lang_id": lang_id,
"lang_name": s.get("language_name") or "unknown",
}
if args.lang and s.get("language_id") != args.lang:
continue
data_by_lang[s.get("language_name") or "unknown"].append(item)
total_files += 1
logging.info(f"Total files: {total_files} in {len(data_by_lang)} languages.")
# 2. Worker config
num_gpus = torch.cuda.device_count()
assert num_gpus > 0, "No GPU found. GPU is required."
total_workers = num_gpus * args.nj_per_gpu
mp.set_start_method("spawn", force=True)
manager = mp.Manager()
rank_queue = manager.Queue()
for _ in range(args.nj_per_gpu):
for rank in range(num_gpus):
rank_queue.put(rank)
# 3. Scheduling: Split languages into chunks
# This prevents one huge language from blocking a worker for too long,
# allows better load balancing across the pool.
tasks = []
chunk_size = args.chunk_size
for lang_name, items in data_by_lang.items():
# Slicing the list into chunks
for i in range(0, len(items), chunk_size):
chunk = items[i : i + chunk_size]
tasks.append({"chunk": chunk, "lang": lang_name})
logging.info(
f"Split data into {len(tasks)} chunks (size ~{chunk_size}). Spawning {total_workers} workers."
)
# 4. Execution
results = []
with ProcessPoolExecutor(
max_workers=total_workers,
initializer=process_init,
initargs=(rank_queue, args.model_card),
) as executor:
futures = []
for task in tasks:
futures.append(
executor.submit(
run_eval_worker, task["chunk"], task["lang"], args.batch_size
)
)
# Unified progress bar
with tqdm(total=total_files, desc="Eval Progress", dynamic_ncols=True) as pbar:
for future in as_completed(futures):
try:
chunk_metrics = future.result()
results.extend(chunk_metrics)
pbar.update(len(chunk_metrics))
except Exception as e:
logging.error(f"Task failed: {e}")
# 5. Metrics Aggregation
wers, inses, deles, subses = [], [], [], []
word_nums = 0
# Store metrics per language
lang_stats = {}
fout = None
if args.decode_path:
os.makedirs(os.path.dirname(args.decode_path), exist_ok=True)
logging.info(f"Saving detailed WER results to: {args.decode_path}")
fout = open(args.decode_path, "w", encoding="utf-8")
for res in results:
wers.append(float(res["wer"]))
inses.append(float(res["insertions"]))
deles.append(float(res["deletions"]))
subses.append(float(res["substitutions"]))
word_nums += res["word_num"]
if fout:
fout.write(
f"{res['wav_path']}\t{res['wer']}\t{res['truth']}\t"
f"{res['hypo']}\t{res['insertions']}\t{res['deletions']}\t"
f"{res['substitutions']}\n"
)
lang_name = res["lang_name"]
# Per language stats
if lang_name not in lang_stats:
lang_stats[lang_name] = {
"inses": [],
"deles": [],
"subses": [],
"word_nums": 0,
}
lang_stats[lang_name]["inses"].append(float(res["insertions"]))
lang_stats[lang_name]["deles"].append(float(res["deletions"]))
lang_stats[lang_name]["subses"].append(float(res["substitutions"]))
lang_stats[lang_name]["word_nums"] += res["word_num"]
print("-" * 50)
# Log per-language stats
per_lang_wers = []
for lang in sorted(lang_stats.keys()):
stats = lang_stats[lang]
if stats["word_nums"] > 0:
lang_wer = log_metrics(
fout,
f"[{lang}]",
stats["inses"],
stats["deles"],
stats["subses"],
stats["word_nums"],
)
per_lang_wers.append(lang_wer)
print("-" * 50)
# Log Macro-average WER
if len(per_lang_wers) > 1:
macro_wer = np.mean(per_lang_wers)
logging.info(
f"Macro-average WER over {len(per_lang_wers)} languages: {macro_wer:.2f}%"
)
if fout:
fout.write(
f"Macro-average WER over {len(per_lang_wers)} languages: {macro_wer:.2f}%\n"
)
count_le_5 = sum(1 for w in per_lang_wers if w <= 5.0)
count_le_10 = sum(1 for w in per_lang_wers if w <= 10.0)
count_le_20 = sum(1 for w in per_lang_wers if w <= 20.0)
stats_msg = (
f"Languages with WER/CER <= 5%: {count_le_5}/{len(per_lang_wers)}\n"
f"Languages with WER/CER <= 10%: {count_le_10}/{len(per_lang_wers)}\n"
f"Languages with WER/CER <= 20%: {count_le_20}/{len(per_lang_wers)}"
)
logging.info("\n" + stats_msg)
if fout:
fout.write(stats_msg + "\n")
# Log overall stats
if word_nums > 0:
log_metrics(fout, "Overall", inses, deles, subses, word_nums)
if fout:
fout.close()
if __name__ == "__main__":
main()