OpenSTBench 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openstbench/__init__.py +143 -0
- openstbench/dataset.py +199 -0
- openstbench/emotion_evaluator.py +262 -0
- openstbench/latency/__init__.py +4 -0
- openstbench/latency/agent.py +108 -0
- openstbench/latency/basics.py +57 -0
- openstbench/latency/cli.py +257 -0
- openstbench/latency/instance.py +176 -0
- openstbench/latency/metrics.py +418 -0
- openstbench/latency/utils.py +295 -0
- openstbench/paralinguistic_evaluator.py +1318 -0
- openstbench/speaker_similarity_evaluator.py +147 -0
- openstbench/speech_quality_evaluator.py +171 -0
- openstbench/translation_evaluator.py +257 -0
- openstbench-0.2.0.dist-info/METADATA +181 -0
- openstbench-0.2.0.dist-info/RECORD +18 -0
- openstbench-0.2.0.dist-info/WHEEL +5 -0
- openstbench-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torchaudio
|
|
3
|
+
import numpy as np
|
|
4
|
+
import os
|
|
5
|
+
import librosa
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
# 如果你想用本地的 Resemblyzer,确保之前项目能 import 到
|
|
9
|
+
try:
|
|
10
|
+
from resemblyzer import preprocess_wav, VoiceEncoder
|
|
11
|
+
RESEMBLYZER_AVAILABLE = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
RESEMBLYZER_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from transformers import Wav2Vec2FeatureExtractor, WavLMForXVector
|
|
17
|
+
TRANSFORMERS_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
TRANSFORMERS_AVAILABLE = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SpeakerSimilarityEvaluator:
|
|
23
|
+
"""
|
|
24
|
+
评估提取两段音频说话人特征(Speaker Embeddings)并计算余弦相似度的打分器。
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self,
|
|
27
|
+
model_type="wavlm",
|
|
28
|
+
device=None,
|
|
29
|
+
wavlm_model_path="microsoft/wavlm-base-plus-sv",
|
|
30
|
+
resemblyzer_weights_path="pretrained.pt"):
|
|
31
|
+
"""
|
|
32
|
+
初始化打分器,仅在初始化时加载一次模型。
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
model_type (str): "wavlm" 或 "resemblyzer" 或 "both"。
|
|
36
|
+
device (str): "cuda", "mps" 或 "cpu",如果为 None 则自动检测。
|
|
37
|
+
wavlm_model_path (str): WavLM 模型的路径或 HuggingFace ID。
|
|
38
|
+
resemblyzer_weights_path (str): Resemblyzer 的 pretrained.pt 权重路径。
|
|
39
|
+
"""
|
|
40
|
+
self.device = device if device else (
|
|
41
|
+
"cuda" if torch.cuda.is_available() else "cpu"
|
|
42
|
+
)
|
|
43
|
+
self.model_type = model_type.lower()
|
|
44
|
+
print(f"Initializing SpeakerSimilarityEvaluator with model(s): {self.model_type} on {self.device}")
|
|
45
|
+
|
|
46
|
+
self.wavlm_feature_extractor = None
|
|
47
|
+
self.wavlm_model = None
|
|
48
|
+
self.resemblyzer_encoder = None
|
|
49
|
+
|
|
50
|
+
if self.model_type in ["wavlm", "both"]:
|
|
51
|
+
if not TRANSFORMERS_AVAILABLE:
|
|
52
|
+
raise ImportError("Please install transformers to use WavLM: pip install transformers")
|
|
53
|
+
print(f"Loading WavLM from {wavlm_model_path}...")
|
|
54
|
+
self.wavlm_feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(wavlm_model_path)
|
|
55
|
+
self.wavlm_model = WavLMForXVector.from_pretrained(wavlm_model_path).to(self.device).eval()
|
|
56
|
+
|
|
57
|
+
if self.model_type in ["resemblyzer", "both"]:
|
|
58
|
+
if not RESEMBLYZER_AVAILABLE:
|
|
59
|
+
raise ImportError("Please ensure 'resemblyzer' module is available in your Python path.")
|
|
60
|
+
print("Loading Resemblyzer VoiceEncoder...")
|
|
61
|
+
# 兼容绝对路径或默认行为
|
|
62
|
+
if os.path.exists(resemblyzer_weights_path):
|
|
63
|
+
self.resemblyzer_encoder = VoiceEncoder(weights_fpath=resemblyzer_weights_path)
|
|
64
|
+
else:
|
|
65
|
+
self.resemblyzer_encoder = VoiceEncoder() # 内部会自动处理没传 path 的默认下载
|
|
66
|
+
|
|
67
|
+
@torch.no_grad()
|
|
68
|
+
def evaluate(self, ref_wav_path, synth_wav_path):
|
|
69
|
+
"""
|
|
70
|
+
计算单对音频的相似度得分。
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
ref_wav_path (str): 参考音频路径(Prompt / Target)
|
|
74
|
+
synth_wav_path (str): 你模型合成出来的音频路径
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
dict: 包含计算结果的字典,例如 {"wavlm": 0.85, "resemblyzer": 0.91}
|
|
78
|
+
"""
|
|
79
|
+
results = {}
|
|
80
|
+
|
|
81
|
+
if self.model_type in ["wavlm", "both"]:
|
|
82
|
+
# WavLM 强制要求 16000 采样率
|
|
83
|
+
ref_waves_16k, _ = librosa.load(ref_wav_path, sr=16000)
|
|
84
|
+
synth_waves_16k, _ = librosa.load(synth_wav_path, sr=16000)
|
|
85
|
+
|
|
86
|
+
ref_inputs = self.wavlm_feature_extractor(
|
|
87
|
+
torch.tensor(ref_waves_16k), padding=True, return_tensors="pt", sampling_rate=16000
|
|
88
|
+
).to(self.device)
|
|
89
|
+
synth_inputs = self.wavlm_feature_extractor(
|
|
90
|
+
torch.tensor(synth_waves_16k), padding=True, return_tensors="pt", sampling_rate=16000
|
|
91
|
+
).to(self.device)
|
|
92
|
+
|
|
93
|
+
ref_embeddings = self.wavlm_model(**ref_inputs).embeddings
|
|
94
|
+
synth_embeddings = self.wavlm_model(**synth_inputs).embeddings
|
|
95
|
+
|
|
96
|
+
# L2 归一化后计算余弦相似度
|
|
97
|
+
ref_embeddings = torch.nn.functional.normalize(ref_embeddings, dim=-1)
|
|
98
|
+
synth_embeddings = torch.nn.functional.normalize(synth_embeddings, dim=-1)
|
|
99
|
+
similarity_wavlm = torch.nn.functional.cosine_similarity(ref_embeddings, synth_embeddings, dim=-1)
|
|
100
|
+
|
|
101
|
+
results["wavlm_similarity"] = similarity_wavlm.item()
|
|
102
|
+
|
|
103
|
+
if self.model_type in ["resemblyzer", "both"]:
|
|
104
|
+
ref_wav_res = preprocess_wav(ref_wav_path)
|
|
105
|
+
synth_wav_res = preprocess_wav(synth_wav_path)
|
|
106
|
+
|
|
107
|
+
ref_embed_res = self.resemblyzer_encoder.embed_utterance(ref_wav_res)
|
|
108
|
+
synth_embed_res = self.resemblyzer_encoder.embed_utterance(synth_wav_res)
|
|
109
|
+
|
|
110
|
+
similarity_res = np.inner(ref_embed_res, synth_embed_res)
|
|
111
|
+
results["resemblyzer_similarity"] = float(similarity_res)
|
|
112
|
+
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
def evaluate_batch(self, ref_wav_paths, synth_wav_paths):
|
|
116
|
+
"""
|
|
117
|
+
批量计算文件夹下的多对音频相似度。
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
ref_wav_paths (list of str): 参考音频列表
|
|
121
|
+
synth_wav_paths (list of str): 合成音频列表(必须与参考列表一一对应)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
dict: 包含各个模型平均分的字典
|
|
125
|
+
"""
|
|
126
|
+
assert len(ref_wav_paths) == len(synth_wav_paths), "参考音频与合成音频数量必须对应!"
|
|
127
|
+
|
|
128
|
+
batch_results = {"details": []}
|
|
129
|
+
wavlm_scores = []
|
|
130
|
+
res_scores = []
|
|
131
|
+
|
|
132
|
+
print("Evaluating speaker similarity...")
|
|
133
|
+
for ref_p, syn_p in tqdm(zip(ref_wav_paths, synth_wav_paths), total=len(ref_wav_paths)):
|
|
134
|
+
res = self.evaluate(ref_p, syn_p)
|
|
135
|
+
batch_results["details"].append({"ref": ref_p, "synth": syn_p, "score": res})
|
|
136
|
+
|
|
137
|
+
if "wavlm_similarity" in res:
|
|
138
|
+
wavlm_scores.append(res["wavlm_similarity"])
|
|
139
|
+
if "resemblyzer_similarity" in res:
|
|
140
|
+
res_scores.append(res["resemblyzer_similarity"])
|
|
141
|
+
|
|
142
|
+
if wavlm_scores:
|
|
143
|
+
batch_results["average_wavlm_similarity"] = sum(wavlm_scores) / len(wavlm_scores)
|
|
144
|
+
if res_scores:
|
|
145
|
+
batch_results["average_resemblyzer_similarity"] = sum(res_scores) / len(res_scores)
|
|
146
|
+
|
|
147
|
+
return batch_results
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import torch
|
|
3
|
+
import torchaudio
|
|
4
|
+
import jiwer
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Optional, Union
|
|
7
|
+
from tqdm import tqdm
|
|
8
|
+
|
|
9
|
+
# 复用现有的加载工具
|
|
10
|
+
from .translation_evaluator import load_text_from_file_or_list, load_audio_from_folder
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import whisper
|
|
14
|
+
except ImportError:
|
|
15
|
+
whisper = None
|
|
16
|
+
|
|
17
|
+
class SpeechQualityEvaluator:
|
|
18
|
+
"""
|
|
19
|
+
语音质量与一致性评测器
|
|
20
|
+
专门用于:
|
|
21
|
+
1. UTMOS (音频自然度/听感质量)
|
|
22
|
+
2. WER/CER 一致性计算 (使用模型自己生成的文本与其生成的音频进行比对)
|
|
23
|
+
"""
|
|
24
|
+
def __init__(self,
|
|
25
|
+
use_wer: bool = True,
|
|
26
|
+
use_utmos: bool = True,
|
|
27
|
+
whisper_model: str = "medium",
|
|
28
|
+
utmos_model_path: Optional[str] = None,
|
|
29
|
+
utmos_ckpt_path: Optional[str] = None,
|
|
30
|
+
device: Optional[str] = None):
|
|
31
|
+
|
|
32
|
+
self.device = device if device else ("cuda" if torch.cuda.is_available() else "cpu")
|
|
33
|
+
self.use_wer = use_wer
|
|
34
|
+
self.use_utmos = use_utmos
|
|
35
|
+
|
|
36
|
+
self.whisper_model_name = whisper_model
|
|
37
|
+
self.utmos_path = utmos_model_path
|
|
38
|
+
self.utmos_ckpt = utmos_ckpt_path
|
|
39
|
+
|
|
40
|
+
self.whisper_model = None
|
|
41
|
+
self.utmos_model = None
|
|
42
|
+
|
|
43
|
+
if self.use_wer:
|
|
44
|
+
self.wer_transform = jiwer.Compose([
|
|
45
|
+
jiwer.ToLowerCase(),
|
|
46
|
+
jiwer.RemovePunctuation(),
|
|
47
|
+
jiwer.RemoveMultipleSpaces(),
|
|
48
|
+
jiwer.Strip(),
|
|
49
|
+
jiwer.RemoveEmptyStrings(),
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
def _load_whisper(self):
|
|
53
|
+
if self.whisper_model is None and self.use_wer:
|
|
54
|
+
if not whisper:
|
|
55
|
+
print("⚠️ Whisper 未安装,跳过加载")
|
|
56
|
+
self.use_wer = False
|
|
57
|
+
return
|
|
58
|
+
print(f"⏳ 正在加载 Whisper ({self.whisper_model_name})...")
|
|
59
|
+
self.whisper_model = whisper.load_model(self.whisper_model_name, device=self.device)
|
|
60
|
+
|
|
61
|
+
def _load_utmos(self):
|
|
62
|
+
if self.utmos_model is None and self.use_utmos:
|
|
63
|
+
print("⏳ 正在加载 UTMOS 模型...")
|
|
64
|
+
try:
|
|
65
|
+
source = "github"
|
|
66
|
+
repo_or_dir = "tarepan/SpeechMOS"
|
|
67
|
+
if self.utmos_path and os.path.exists(self.utmos_path):
|
|
68
|
+
source = "local"
|
|
69
|
+
repo_or_dir = self.utmos_path
|
|
70
|
+
|
|
71
|
+
load_pretrained = (self.utmos_ckpt is None)
|
|
72
|
+
self.utmos_model = torch.hub.load(
|
|
73
|
+
repo_or_dir, "utmos22_strong", source=source, trust_repo=True, pretrained=load_pretrained
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if self.utmos_ckpt and os.path.exists(self.utmos_ckpt):
|
|
77
|
+
state_dict = torch.load(self.utmos_ckpt, map_location=self.device)
|
|
78
|
+
self.utmos_model.load_state_dict(state_dict)
|
|
79
|
+
|
|
80
|
+
self.utmos_model.to(self.device).eval()
|
|
81
|
+
except Exception as e:
|
|
82
|
+
print(f"⚠️ UTMOS 模型加载失败: {e}")
|
|
83
|
+
self.use_utmos = False
|
|
84
|
+
|
|
85
|
+
def _transcribe(self, audio_paths: List[str]) -> List[str]:
|
|
86
|
+
self._load_whisper()
|
|
87
|
+
if not self.whisper_model:
|
|
88
|
+
return [""] * len(audio_paths)
|
|
89
|
+
|
|
90
|
+
results = []
|
|
91
|
+
for path in tqdm(audio_paths, desc="🎙️ Whisper 转写中"):
|
|
92
|
+
res = self.whisper_model.transcribe(path, fp16=torch.cuda.is_available() and "cuda" in self.device)
|
|
93
|
+
results.append(res["text"].strip())
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
def _compute_utmos(self, audio_paths: List[str]) -> float:
|
|
97
|
+
self._load_utmos()
|
|
98
|
+
if not self.utmos_model: return 0.0
|
|
99
|
+
|
|
100
|
+
scores = []
|
|
101
|
+
target_sr = 16000
|
|
102
|
+
for path in tqdm(audio_paths, desc="🎧 计算 UTMOS"):
|
|
103
|
+
try:
|
|
104
|
+
wave, sr = torchaudio.load(path)
|
|
105
|
+
if sr != target_sr:
|
|
106
|
+
wave = torchaudio.functional.resample(wave, sr, target_sr)
|
|
107
|
+
if wave.shape[0] > 1:
|
|
108
|
+
wave = torch.mean(wave, dim=0, keepdim=True)
|
|
109
|
+
|
|
110
|
+
wave = wave.to(self.device)
|
|
111
|
+
with torch.no_grad():
|
|
112
|
+
s = self.utmos_model(wave, target_sr)
|
|
113
|
+
scores.append(s.item())
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"⚠️ UTMOS Error on {path}: {e}")
|
|
116
|
+
|
|
117
|
+
return sum(scores) / len(scores) if scores else 0.0
|
|
118
|
+
|
|
119
|
+
def _preprocess_for_wer(self, text: str, lang: str) -> str:
|
|
120
|
+
text = self.wer_transform(text)
|
|
121
|
+
if lang in ["zh", "ja", "ko"]:
|
|
122
|
+
text = text.replace(" ", "")
|
|
123
|
+
return " ".join(list(text))
|
|
124
|
+
return text
|
|
125
|
+
|
|
126
|
+
def evaluate_all(self,
|
|
127
|
+
target_audio: Union[List[str], str],
|
|
128
|
+
target_text: Optional[Union[List[str], str]] = None,
|
|
129
|
+
target_lang: str = "en") -> Dict[str, float]:
|
|
130
|
+
"""
|
|
131
|
+
:param target_audio: 模型生成的音频路径列表或文件夹
|
|
132
|
+
:param target_text: 模型同步生成的文本,用作 WER 计算的参照
|
|
133
|
+
:param target_lang: 音频与文本的目标语言
|
|
134
|
+
"""
|
|
135
|
+
results = {}
|
|
136
|
+
print(f"\n--- 开始语音质量评测 (Target Lang: {target_lang}) ---")
|
|
137
|
+
|
|
138
|
+
# 加载目标音频
|
|
139
|
+
if isinstance(target_audio, str) and os.path.exists(target_audio) and os.path.isdir(target_audio):
|
|
140
|
+
audio_paths = load_audio_from_folder(target_audio)
|
|
141
|
+
else:
|
|
142
|
+
audio_paths = target_audio if isinstance(target_audio, list) else [target_audio]
|
|
143
|
+
|
|
144
|
+
# 1. 计算 UTMOS
|
|
145
|
+
if self.use_utmos:
|
|
146
|
+
print(" ➤ 计算 UTMOS (音频自然度)...")
|
|
147
|
+
results["UTMOS"] = round(self._compute_utmos(audio_paths), 4)
|
|
148
|
+
|
|
149
|
+
# 2. 计算 Consistency WER/CER
|
|
150
|
+
if self.use_wer:
|
|
151
|
+
if not target_text:
|
|
152
|
+
print(" ⚠️ 未提供 target_text (模型同时生成的文本),无法计算文本-语音一致性,跳过 WER/CER。")
|
|
153
|
+
else:
|
|
154
|
+
texts = load_text_from_file_or_list(target_text, "target_text")
|
|
155
|
+
if len(texts) != len(audio_paths):
|
|
156
|
+
raise ValueError(f"生成的文本行数 ({len(texts)}) 与 生成的音频数量 ({len(audio_paths)}) 不一致!")
|
|
157
|
+
|
|
158
|
+
print(" ➤ 正在进行音频转写,准备计算一致性...")
|
|
159
|
+
asr_texts = self._transcribe(audio_paths)
|
|
160
|
+
|
|
161
|
+
clean_refs = [self._preprocess_for_wer(t, target_lang) for t in texts]
|
|
162
|
+
clean_hyps = [self._preprocess_for_wer(t, target_lang) for t in asr_texts]
|
|
163
|
+
|
|
164
|
+
if clean_refs:
|
|
165
|
+
error_rate = jiwer.wer(clean_refs, clean_hyps)
|
|
166
|
+
metric_name = "CER_Consistency" if target_lang in ['zh', 'ja', 'ko'] else "WER_Consistency"
|
|
167
|
+
results[metric_name] = round(error_rate, 4)
|
|
168
|
+
else:
|
|
169
|
+
print(" ⚠️ 提供的生成文本清洗后为空,无法计算分布错误率。")
|
|
170
|
+
|
|
171
|
+
return results
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MultiMetric Eval - 文本翻译评测工具 (Text Metrics Only)
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import gc
|
|
6
|
+
import json
|
|
7
|
+
import numpy as np
|
|
8
|
+
import sacrebleu
|
|
9
|
+
import torch
|
|
10
|
+
from typing import Dict, List, Optional, Union
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# ==================== 配置 ====================
|
|
14
|
+
|
|
15
|
+
CACHE_PATHS = {
|
|
16
|
+
"huggingface": os.path.expanduser("~/.cache/huggingface/hub"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# ==================== 可选依赖 ====================
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from bleurt_pytorch import BleurtConfig, BleurtForSequenceClassification, BleurtTokenizer
|
|
23
|
+
HAS_BLEURT = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
HAS_BLEURT = False
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from comet import download_model, load_from_checkpoint
|
|
29
|
+
except ImportError:
|
|
30
|
+
download_model = None
|
|
31
|
+
load_from_checkpoint = None
|
|
32
|
+
|
|
33
|
+
# ==================== 输入加载工具 ====================
|
|
34
|
+
|
|
35
|
+
def load_text_from_file_or_list(input_data: Union[str, List[str]], name: str = "text") -> List[str]:
|
|
36
|
+
"""
|
|
37
|
+
通用文本加载器
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(input_data, list):
|
|
40
|
+
return input_data
|
|
41
|
+
|
|
42
|
+
if not isinstance(input_data, str):
|
|
43
|
+
raise ValueError(f"{name} 必须是 文件路径(str) 或 文本列表(List[str])")
|
|
44
|
+
|
|
45
|
+
path = Path(input_data)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
raise FileNotFoundError(f"{name} 文件不存在: {input_data}")
|
|
48
|
+
|
|
49
|
+
suffix = path.suffix.lower()
|
|
50
|
+
|
|
51
|
+
if suffix == ".json":
|
|
52
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
|
|
55
|
+
if isinstance(data, list):
|
|
56
|
+
if len(data) == 0: return []
|
|
57
|
+
if isinstance(data[0], str): return data
|
|
58
|
+
if isinstance(data[0], dict):
|
|
59
|
+
for key in ["target_text", "hypothesis", "text", "ref", "reference", "src", "source"]:
|
|
60
|
+
if key in data[0]:
|
|
61
|
+
return [item[key] for item in data]
|
|
62
|
+
raise ValueError(f"JSON 列表项中未找到常见文本字段")
|
|
63
|
+
|
|
64
|
+
if isinstance(data, dict):
|
|
65
|
+
for key in ["target_text", "hypothesis", "text", "ref", "reference", "src", "source"]:
|
|
66
|
+
if key in data:
|
|
67
|
+
return data[key]
|
|
68
|
+
raise ValueError("JSON 字典中未找到常见文本字段")
|
|
69
|
+
|
|
70
|
+
raise ValueError("不支持的 JSON 格式")
|
|
71
|
+
else:
|
|
72
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
73
|
+
return [line.strip() for line in f if line.strip()]
|
|
74
|
+
|
|
75
|
+
def load_audio_from_folder(folder_path: str, extensions: tuple = (".wav", ".mp3", ".flac")) -> List[str]:
|
|
76
|
+
"""从文件夹加载音频文件路径(向后兼容暴露给 SpeechQualityEvaluator 使用)"""
|
|
77
|
+
folder = Path(folder_path)
|
|
78
|
+
if not folder.exists():
|
|
79
|
+
raise FileNotFoundError(f"文件夹不存在: {folder_path}")
|
|
80
|
+
audio_files = []
|
|
81
|
+
for ext in extensions:
|
|
82
|
+
audio_files.extend(folder.glob(f"*{ext}"))
|
|
83
|
+
audio_files = sorted(audio_files, key=lambda x: x.stem)
|
|
84
|
+
if not audio_files:
|
|
85
|
+
raise ValueError(f"文件夹中没有音频文件: {folder_path}")
|
|
86
|
+
return [str(f) for f in audio_files]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ==================== 评测器核心类 ====================
|
|
90
|
+
|
|
91
|
+
DEFAULT_BLEURT_MODEL = "lucadiliello/BLEURT-20"
|
|
92
|
+
|
|
93
|
+
class TranslationEvaluator:
|
|
94
|
+
"""
|
|
95
|
+
文本侧翻译质量评测器:支持 文本翻译提取与对比 (BLEU, COMET, BLEURT...)
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self,
|
|
99
|
+
use_bleu: bool = True,
|
|
100
|
+
use_chrf: bool = True,
|
|
101
|
+
use_comet: bool = True,
|
|
102
|
+
use_bleurt: bool = False,
|
|
103
|
+
comet_model: str = "Unbabel/wmt22-comet-da",
|
|
104
|
+
bleurt_path: Optional[str] = None,
|
|
105
|
+
bleurt_model: Optional[str] = None,
|
|
106
|
+
device: Optional[str] = None):
|
|
107
|
+
|
|
108
|
+
self.use_bleu = use_bleu
|
|
109
|
+
self.use_chrf = use_chrf
|
|
110
|
+
self.use_comet = use_comet
|
|
111
|
+
self.use_bleurt = use_bleurt
|
|
112
|
+
|
|
113
|
+
if device is not None:
|
|
114
|
+
self.device = device
|
|
115
|
+
elif torch.cuda.is_available():
|
|
116
|
+
self.device = "cuda"
|
|
117
|
+
else:
|
|
118
|
+
self.device = "cpu"
|
|
119
|
+
|
|
120
|
+
print(f"🚀 初始化 Translation Evaluator (纯文本侧) (设备: {self.device})")
|
|
121
|
+
|
|
122
|
+
# 加载语言计算模型
|
|
123
|
+
self.comet = None
|
|
124
|
+
if self.use_comet:
|
|
125
|
+
self.comet = self._load_comet(comet_model)
|
|
126
|
+
|
|
127
|
+
self.bleurt_model = None
|
|
128
|
+
self.bleurt_tokenizer = None
|
|
129
|
+
if self.use_bleurt:
|
|
130
|
+
self._load_bleurt(bleurt_path, bleurt_model)
|
|
131
|
+
|
|
132
|
+
print("✅ 翻译文本评测量度系统就绪!")
|
|
133
|
+
|
|
134
|
+
def __enter__(self): return self
|
|
135
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
136
|
+
self.cleanup()
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def cleanup(self):
|
|
140
|
+
for attr in ['comet', 'bleurt_model', 'bleurt_tokenizer']:
|
|
141
|
+
if hasattr(self, attr) and getattr(self, attr) is not None:
|
|
142
|
+
delattr(self, attr)
|
|
143
|
+
if torch.cuda.is_available():
|
|
144
|
+
torch.cuda.empty_cache()
|
|
145
|
+
gc.collect()
|
|
146
|
+
|
|
147
|
+
def _load_comet(self, model_name: str):
|
|
148
|
+
if not download_model:
|
|
149
|
+
print("⚠️ COMET 未安装,跳过")
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
cache = os.path.join(CACHE_PATHS["huggingface"], f"models--{model_name.replace('/', '--')}")
|
|
153
|
+
status = "[Local]" if os.path.exists(cache) else "[Online]"
|
|
154
|
+
print(f"⏳ {status} 加载 COMET: {model_name}")
|
|
155
|
+
model = load_from_checkpoint(download_model(model_name))
|
|
156
|
+
if self.device.startswith("cuda"):
|
|
157
|
+
model = model.to(self.device)
|
|
158
|
+
return model
|
|
159
|
+
except Exception as e:
|
|
160
|
+
print(f"❌ COMET 加载失败: {e}")
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _load_bleurt(self, path: Optional[str], model_name: Optional[str]):
|
|
164
|
+
if not HAS_BLEURT:
|
|
165
|
+
print("⚠️ bleurt-pytorch 未安装,跳过加载")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
model_source = path if (path and os.path.exists(path)) else (model_name or DEFAULT_BLEURT_MODEL)
|
|
169
|
+
print(f"⏳ 加载 BLEURT: {model_source}")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
self.bleurt_tokenizer = BleurtTokenizer.from_pretrained(model_source)
|
|
173
|
+
self.bleurt_model = BleurtForSequenceClassification.from_pretrained(model_source)
|
|
174
|
+
self.bleurt_model = self.bleurt_model.to(self.device).eval()
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(f"❌ BLEURT 加载失败: {e}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _get_bleu_tokenizer_name(self, lang: str) -> str:
|
|
180
|
+
if lang == 'zh': return 'zh'
|
|
181
|
+
elif lang == 'ja': return 'ja-mecab'
|
|
182
|
+
elif lang == 'ko': return 'ko-mecab'
|
|
183
|
+
else: return '13a'
|
|
184
|
+
|
|
185
|
+
def _compute_bleurt_score(self, references: List[str], candidates: List[str]) -> float:
|
|
186
|
+
all_scores = []
|
|
187
|
+
batch_size = 32
|
|
188
|
+
for i in range(0, len(references), batch_size):
|
|
189
|
+
br = references[i:i+batch_size]
|
|
190
|
+
bc = candidates[i:i+batch_size]
|
|
191
|
+
with torch.no_grad():
|
|
192
|
+
inputs = self.bleurt_tokenizer(br, bc, padding='longest', truncation=True, max_length=512, return_tensors='pt')
|
|
193
|
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
|
194
|
+
scores = self.bleurt_model(**inputs).logits.flatten().tolist()
|
|
195
|
+
all_scores.extend(scores)
|
|
196
|
+
return float(np.mean(all_scores))
|
|
197
|
+
|
|
198
|
+
def evaluate_all(
|
|
199
|
+
self,
|
|
200
|
+
reference: Union[List[str], str],
|
|
201
|
+
target_text: Union[List[str], str],
|
|
202
|
+
source: Optional[Union[List[str], str]] = None,
|
|
203
|
+
target_lang: str = "en"
|
|
204
|
+
) -> Dict[str, float]:
|
|
205
|
+
"""
|
|
206
|
+
Args:
|
|
207
|
+
reference: 数据集自带的真实标准基准翻译参考 (List 或 文件路径)
|
|
208
|
+
target_text: 翻译模型输出的直接文本翻译结果 (List 或 文件路径)
|
|
209
|
+
source: 选填, 语言原始文本 (COMET强制依赖)
|
|
210
|
+
target_lang: 影响 BLEU的 Tokenizer 选择
|
|
211
|
+
"""
|
|
212
|
+
results = {}
|
|
213
|
+
print(f"\n--- 开始文本翻译质量评测 (Target Lang: {target_lang}) ---")
|
|
214
|
+
|
|
215
|
+
final_ref = load_text_from_file_or_list(reference, "Reference")
|
|
216
|
+
final_text = load_text_from_file_or_list(target_text, "Target Text")
|
|
217
|
+
|
|
218
|
+
final_src = None
|
|
219
|
+
if source:
|
|
220
|
+
final_src = load_text_from_file_or_list(source, "Source")
|
|
221
|
+
if len(final_src) != len(final_ref):
|
|
222
|
+
raise ValueError("Source 与 Reference 数量不一致")
|
|
223
|
+
|
|
224
|
+
if len(final_text) != len(final_ref):
|
|
225
|
+
raise ValueError("Target Text 与 Reference 数量不一致")
|
|
226
|
+
|
|
227
|
+
# 1. sacreBLEU
|
|
228
|
+
if self.use_bleu:
|
|
229
|
+
tokenizer_name = self._get_bleu_tokenizer_name(target_lang)
|
|
230
|
+
try:
|
|
231
|
+
results["sacreBLEU"] = sacrebleu.corpus_bleu(final_text, [final_ref], tokenize=tokenizer_name).score
|
|
232
|
+
except Exception as e:
|
|
233
|
+
results["sacreBLEU"] = -1.0
|
|
234
|
+
|
|
235
|
+
# 2. chrF++
|
|
236
|
+
if self.use_chrf:
|
|
237
|
+
try:
|
|
238
|
+
results["chrF++"] = sacrebleu.corpus_chrf(final_text, [final_ref], word_order=2).score
|
|
239
|
+
except: results["chrF++"] = -1.0
|
|
240
|
+
|
|
241
|
+
# 3. BLEURT
|
|
242
|
+
if self.use_bleurt and self.bleurt_model:
|
|
243
|
+
try:
|
|
244
|
+
results["BLEURT"] = self._compute_bleurt_score(final_ref, final_text)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
results["BLEURT"] = -1.0
|
|
247
|
+
|
|
248
|
+
# 4. COMET
|
|
249
|
+
if self.use_comet and self.comet and final_src:
|
|
250
|
+
try:
|
|
251
|
+
data = [{"src": s, "mt": t, "ref": r} for s, t, r in zip(final_src, final_text, final_ref)]
|
|
252
|
+
gpus = 1 if self.device.startswith("cuda") else 0
|
|
253
|
+
results["COMET"] = self.comet.predict(data, batch_size=8, gpus=gpus).system_score
|
|
254
|
+
except Exception as e:
|
|
255
|
+
results["COMET"] = -1.0
|
|
256
|
+
|
|
257
|
+
return {k: round(v, 4) if v >= 0 else v for k, v in results.items()}
|