| |
| |
|
|
| import argparse |
| import base64 |
| import json |
| import os |
| import re |
| import time |
| from collections import defaultdict |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from pathlib import Path |
| from typing import Any, Dict, List, Tuple |
|
|
| from openai import OpenAI |
| from tqdm import tqdm |
|
|
| try: |
| import matplotlib.pyplot as plt |
| except ImportError: |
| plt = None |
|
|
| try: |
| import pandas as pd |
| except ImportError: |
| pd = None |
|
|
| if plt is not None: |
| plt.rcParams.update({"font.size": 10}) |
|
|
| SUB_CATS = { |
| "acoustic_attributes": [ |
| "acoustic_attributes/age", |
| "acoustic_attributes/speed", |
| "acoustic_attributes/gender", |
| "acoustic_attributes/emotion", |
| "acoustic_attributes/pitch", |
| "acoustic_attributes/volume", |
| "acoustic_attributes/composite_properties", |
| ], |
| "instruction": [ |
| "instruction/emotion", |
| "instruction/variation", |
| "instruction/style", |
| ], |
| "role_play": [ |
| "role_play/character", |
| "role_play/scenario", |
| ], |
| "empathy": [ |
| "empathy/anger", |
| "empathy/sadness_disappointment", |
| "empathy/anxiety_fear", |
| "empathy/joy_excitement", |
| ], |
| } |
|
|
|
|
| |
|
|
| def load_prompts(prompts_dir: Path) -> Dict[str, str]: |
| mapping = {} |
| for file in prompts_dir.glob("*.txt"): |
| mapping[file.stem] = file.read_text(encoding="utf-8") |
| if not mapping: |
| raise RuntimeError(f"No *.txt prompt files found in {prompts_dir}") |
| return mapping |
|
|
|
|
| def construct_prompt(template: str, instruction: str, ability: str) -> str: |
| return template.format(instruction_type=ability, input_instruction=instruction) |
|
|
|
|
| def debug_print_input(sample, root_dir, prompts): |
| """跑之前调用这个,肉眼确认输入""" |
| big_cat = sample["ability"].split("/")[0] |
| prompt_text = construct_prompt( |
| prompts[big_cat], |
| instruction=sample.get("instruct_text", ""), |
| ability=sample["ability"], |
| ) |
| audio_path = Path(sample.get("final_audio_path") or root_dir / sample.get("response_audio_path", "")) |
|
|
| print("=" * 60) |
| print(f"[id] {sample['id']}") |
| print(f"[ability] {sample['ability']}") |
| print(f"[instruct_text]{sample['instruct_text']}") |
| print(f"[audio_path] {audio_path} exists={audio_path.exists()}") |
| print(f"[prompt末尾50字]{prompt_text[-200:]}") |
| print("=" * 60) |
|
|
|
|
| def ensure_dir(p: Path): |
| p.mkdir(parents=True, exist_ok=True) |
|
|
|
|
| def safe_float(x: Any): |
| try: |
| return float(x) |
| except (TypeError, ValueError): |
| return None |
|
|
|
|
| def normalize_score_str(x: Any) -> str: |
| v = safe_float(x) |
| if v is None: |
| return "" |
| if 1 <= v <= 5: |
| if float(v).is_integer(): |
| return str(int(v)) |
| return str(v).rstrip("0").rstrip(".") |
| return "" |
|
|
|
|
| def parse_score(model_reply: str) -> str: |
| if not model_reply: |
| return "" |
|
|
| text = str(model_reply).strip() |
|
|
| |
| matches = re.findall(r"\[\[\s*([0-9]+(?:\.[0-9]+)?)\s*]]", text, flags=re.S) |
| for s in reversed(matches): |
| score = normalize_score_str(s) |
| if score: |
| return score |
|
|
| |
| score = normalize_score_str(text) |
| if score: |
| return score |
|
|
| |
| matches = re.findall( |
| r"(?:final\s+score|score|rating|gemini_score|分数|评分|最终分数)" |
| r"\s*[::=]?\s*\[?\[?\s*([1-5](?:\.[0-9]+)?)(?!\d)\s*\]?\]?", |
| text, |
| flags=re.I, |
| ) |
| for s in reversed(matches): |
| score = normalize_score_str(s) |
| if score: |
| return score |
|
|
| return "" |
|
|
|
|
| def encode_audio_base64(audio_path: Path) -> str: |
| with audio_path.open("rb") as f: |
| return base64.b64encode(f.read()).decode("utf-8") |
|
|
|
|
| def build_retry_prompt(original_prompt: str, attempt: int) -> str: |
| return f""" |
| {original_prompt} |
| |
| IMPORTANT FORMAT REQUIREMENT: |
| Your previous response did not contain a valid score. |
| |
| You MUST now output exactly ONE score between 1 and 5. |
| Do not explain. |
| Do not output any extra words. |
| Preferred format: [[score]] |
| |
| Valid examples: |
| [[1]] |
| [[2]] |
| [[3]] |
| [[4]] |
| [[5]] |
| |
| This is retry attempt #{attempt}. |
| """.strip() |
|
|
|
|
| def normalize_id(value: Any): |
| if value is None: |
| return None |
| try: |
| return int(value) |
| except (TypeError, ValueError): |
| return str(value) |
|
|
|
|
| def load_dataset_metadata(dataset_metadata_path: Path) -> Dict[Any, Dict[str, Any]]: |
| dataset_map: Dict[Any, Dict[str, Any]] = {} |
| if not dataset_metadata_path or not Path(dataset_metadata_path).exists(): |
| return dataset_map |
|
|
| with Path(dataset_metadata_path).open(encoding="utf-8") as f: |
| for line in f: |
| try: |
| d = json.loads(line) |
| except json.JSONDecodeError: |
| continue |
|
|
| instruct_id = d.get("instruct_id", d.get("id")) |
| item = { |
| "id": d.get("id", instruct_id), |
| "instruct_id": instruct_id, |
| "ability": d.get("ability", ""), |
| "instruct_text": d.get("instruct_text") or d.get("audio_content", ""), |
| "response_audio_path": d.get("response_audio_path") or d.get("file_name", ""), |
| } |
| for key in (d.get("id"), instruct_id): |
| norm = normalize_id(key) |
| if norm is not None: |
| dataset_map[norm] = item |
|
|
| return dataset_map |
|
|
|
|
| def candidate_path(root_dir: Path, path_str: Any) -> Path: |
| if not path_str: |
| return None |
| path = Path(str(path_str).strip()) |
| if path.is_absolute(): |
| return path |
| return root_dir / path |
|
|
|
|
| def resolve_audio_path(root_dir: Path, sample: Dict[str, Any], dataset_entry: Dict[str, Any] = None) -> str: |
| iid = sample.get("instruct_id", sample.get("id")) |
| candidates = [] |
|
|
| for key in ("final_audio_path", "response_audio_path", "file_name", "response_wav"): |
| path = candidate_path(root_dir, sample.get(key)) |
| if path: |
| candidates.append(path) |
|
|
| if dataset_entry: |
| path = candidate_path(root_dir, dataset_entry.get("response_audio_path")) |
| if path: |
| candidates.append(path) |
|
|
| if iid is not None: |
| candidates.append(root_dir / f"{iid}.wav") |
|
|
| seen = set() |
| for path in candidates: |
| key = str(path) |
| if key in seen: |
| continue |
| seen.add(key) |
| if path.exists(): |
| return str(path) |
| return "" |
|
|
|
|
| |
|
|
| def call_proxy_gemini_api( |
| prompt_text: str, |
| response_audio_path: Path, |
| model_name: str, |
| max_retry: int, |
| sleep_between_retry: int, |
| api_key: str, |
| base_url: str, |
| temperature: float = 1.0, |
| top_p: float = 0.7, |
| max_tokens: int = 4096, |
| verbose: bool = False, |
| ) -> Tuple[bool, str, str]: |
|
|
| client = OpenAI(api_key=api_key, base_url=base_url) |
| last_err_msg = "" |
| last_reply_text = "" |
| audio_b64 = encode_audio_base64(response_audio_path) |
| audio_format = response_audio_path.suffix.replace(".", "").lower() or "wav" |
|
|
| for attempt in range(1, max_retry + 1): |
| try: |
| current_prompt = prompt_text if attempt == 1 else build_retry_prompt(prompt_text, attempt) |
|
|
| content = [{"type": "text", "text": current_prompt}] |
| content.append( |
| { |
| "type": "input_audio", |
| "input_audio": { |
| "data": audio_b64, |
| "format": audio_format, |
| }, |
| } |
| ) |
|
|
| resp = client.chat.completions.create( |
| model=model_name, |
| messages=[{"role": "user", "content": content}], |
| max_tokens=max_tokens, |
| temperature=temperature, |
| top_p=top_p, |
| ) |
|
|
| if not hasattr(resp, "choices") or not resp.choices: |
| last_err_msg = f"Proxy API bad response: {resp}" |
| print(f"[Attempt {attempt}/{max_retry}] bad response: {last_err_msg}") |
| else: |
| reply_text = resp.choices[0].message.content or "" |
| last_reply_text = reply_text |
| score_str = parse_score(reply_text) |
|
|
| if verbose: |
| print(f"[Attempt {attempt}/{max_retry}] raw reply: {reply_text[:200]}") |
|
|
| if score_str: |
| return True, reply_text, score_str |
|
|
| last_err_msg = f"Unable to parse score from reply: {reply_text[:200]}" |
| print(f"[Attempt {attempt}/{max_retry}] parse failed") |
|
|
| except Exception as e: |
| last_err_msg = str(e) |
| print(f"[Attempt {attempt}/{max_retry}] API call error: {last_err_msg}") |
|
|
| if attempt < max_retry: |
| time.sleep(sleep_between_retry) |
|
|
| return False, (last_reply_text or last_err_msg), "" |
|
|
|
|
| |
|
|
| def evaluate_one( |
| sample: dict, |
| root_dir: Path, |
| prompts: Dict[str, str], |
| out_dir: Path, |
| model_name: str, |
| max_retry: int, |
| sleep_between_retry: int, |
| api_key: str, |
| base_url: str, |
| fallback_score: str, |
| temperature: float, |
| top_p: float, |
| max_tokens: int, |
| verbose: bool, |
| ) -> dict: |
| ability = sample["ability"] |
| big_cat, small_cat = ability.split("/", 1) |
| sample_model_name = sample.get("model_name", "model_eval") |
| sample_id = sample.get("id") or sample.get("instruct_id") or "unknown_id" |
| sample["id"] = sample_id |
|
|
| if big_cat not in prompts: |
| error_msg = f"Prompt template not found for category {big_cat}" |
| sample["gemini_score"] = fallback_score |
| sample["gemini_status"] = "fallback_prompt_missing" |
| sample["gemini_error"] = error_msg |
| sample["gemini_raw"] = error_msg |
| return sample |
|
|
| response_audio_path_str = sample.get("final_audio_path", "") |
|
|
| if not response_audio_path_str: |
| error_msg = f"Audio file not found for ID {sample_id} in any expected location." |
| sample["gemini_score"] = fallback_score |
| sample["gemini_status"] = "fallback_audio_not_found" |
| sample["gemini_error"] = error_msg |
| sample["gemini_raw"] = error_msg |
| return sample |
|
|
| response_audio_path = Path(response_audio_path_str) |
|
|
| if not response_audio_path.exists(): |
| error_msg = f"Audio file not found: {response_audio_path}" |
| sample["gemini_score"] = fallback_score |
| sample["gemini_status"] = "fallback_audio_not_found" |
| sample["gemini_error"] = error_msg |
| sample["gemini_raw"] = error_msg |
| return sample |
|
|
| prompt_text = construct_prompt( |
| prompts[big_cat], |
| instruction=sample.get("audio_content", "") or sample.get("instruct_text", ""), |
| ability=sample["ability"], |
| ) |
|
|
| success, reply, score = call_proxy_gemini_api( |
| prompt_text=prompt_text, |
| response_audio_path=response_audio_path, |
| model_name=model_name, |
| max_retry=min(max_retry, 5), |
| sleep_between_retry=sleep_between_retry, |
| api_key=api_key, |
| base_url=base_url, |
| temperature=temperature, |
| top_p=top_p, |
| max_tokens=max_tokens, |
| verbose=verbose, |
| ) |
| if success: |
| sample["gemini_score"] = score |
| sample["gemini_raw"] = reply |
| sample["gemini_status"] = "success" |
| else: |
| sample["gemini_score"] = fallback_score |
| sample["gemini_raw"] = reply |
| sample["gemini_status"] = "fallback_after_5_failures" |
| sample["gemini_error"] = f"Failed after {min(max_retry, 5)} attempts" |
|
|
| |
| debug_path = out_dir / "debug_inputs_outputs.txt" |
| with debug_path.open("a", encoding="utf-8") as dbg: |
| dbg.write("=" * 80 + "\n") |
| dbg.write(f"[ID] {sample_id}\n") |
| dbg.write(f"[ABILITY] {ability}\n") |
| dbg.write(f"[AUDIO] {response_audio_path}\n") |
| dbg.write(f"[INSTRUCT] {sample.get('instruct_text', '')}\n") |
| dbg.write(f"[PROMPT]\n{prompt_text}\n") |
| dbg.write(f"[RAW OUTPUT]\n{sample.get('gemini_raw', '')}\n") |
| dbg.write(f"[SCORE] {sample.get('gemini_score', '')}\n") |
| dbg.write(f"[STATUS] {sample.get('gemini_status', '')}\n") |
| dbg.write("=" * 80 + "\n\n") |
|
|
| save_path = ( |
| out_dir |
| / sample_model_name |
| / "gemini_proxy_res" |
| / big_cat |
| / small_cat |
| / f"{sample_id}.txt" |
| ) |
| ensure_dir(save_path.parent) |
| save_path.write_text(sample.get("gemini_raw", ""), encoding="utf-8") |
| return sample |
|
|
|
|
| def load_processed_ids_and_clean_failures(scored_jsonl: Path, overwrite: bool) -> Tuple[set, int]: |
| processed = set() |
| cleaned_count = 0 |
|
|
| if not scored_jsonl.exists() or overwrite: |
| return processed, cleaned_count |
|
|
| valid_lines = [] |
| total_lines = 0 |
|
|
| with scored_jsonl.open("r", encoding="utf-8") as f: |
| for line in f: |
| total_lines += 1 |
| line = line.strip() |
| if not line: |
| continue |
|
|
| try: |
| rec = json.loads(line) |
| gemini_score = rec.get("gemini_score", "") |
| if safe_float(gemini_score) is not None: |
| processed.add(normalize_id(rec.get("id"))) |
| valid_lines.append(line) |
| else: |
| cleaned_count += 1 |
| print(f"🗑️ Removing failed record ID {rec.get('id')}: {gemini_score}") |
| except Exception as e: |
| cleaned_count += 1 |
| print(f"🗑️ Removing invalid JSON line: {str(e)}") |
|
|
| if cleaned_count > 0: |
| print(f"📝 Cleaning {cleaned_count} failed records from {scored_jsonl}") |
| with scored_jsonl.open("w", encoding="utf-8") as f: |
| for line in valid_lines: |
| f.write(line + "\n") |
| print(f"✅ Cleaned file saved. Kept {len(valid_lines)} valid records out of {total_lines} total lines.") |
|
|
| return processed, cleaned_count |
|
|
|
|
| def remove_duplicates_from_tasks(tasks: List[dict]) -> Tuple[List[dict], Dict[str, int]]: |
| seen_ids = set() |
| unique_tasks = [] |
| duplicate_count = 0 |
| duplicate_details = defaultdict(int) |
|
|
| for task in tasks: |
| task_id = normalize_id(task.get("id")) |
| ability = task.get("ability", "unknown") |
|
|
| if task_id not in seen_ids: |
| seen_ids.add(task_id) |
| unique_tasks.append(task) |
| else: |
| duplicate_count += 1 |
| duplicate_details[ability] += 1 |
| print(f"🔄 Removing duplicate sample ID: {task_id} (ability: {ability})") |
|
|
| stats = { |
| "total_duplicates": duplicate_count, |
| "by_ability": dict(duplicate_details), |
| } |
| return unique_tasks, stats |
|
|
|
|
| def eval_all_samples( |
| root_dir: Path, |
| metadata_path: Path, |
| prompts_dir: Path, |
| out_dir: Path, |
| max_per_ability: int, |
| concurrency: int, |
| overwrite: bool, |
| model_name: str, |
| max_retry: int, |
| sleep_between_retry: int, |
| api_key: str, |
| base_url: str, |
| fallback_score: str, |
| dataset_metadata_path: Path = None, |
| temperature: float = 1.0, |
| top_p: float = 0.7, |
| max_tokens: int = 4096, |
| verbose: bool = False, |
| ) -> Path: |
| prompts = load_prompts(prompts_dir) |
|
|
| dataset_map = load_dataset_metadata(dataset_metadata_path) if dataset_metadata_path else {} |
| if dataset_map: |
| print(f"📚 Loaded {len(dataset_map)} entries from dataset: {dataset_metadata_path}") |
|
|
| scored_jsonl = out_dir / "metadata_with_score.jsonl" |
| processed_ids, _ = load_processed_ids_and_clean_failures(scored_jsonl, overwrite) |
|
|
| tasks: List[dict] = [] |
| per_ability_counter = defaultdict(int) |
| skipped_missing_dataset = 0 |
| skipped_missing_required = 0 |
|
|
| print(f"📖 Reading samples from: {metadata_path}") |
| with metadata_path.open(encoding="utf-8") as fin: |
| for line in fin: |
| try: |
| sample = json.loads(line) |
| except json.JSONDecodeError: |
| continue |
|
|
| iid = sample.get("instruct_id", sample.get("id")) |
| iid_norm = normalize_id(iid) |
| dataset_entry = dataset_map.get(iid_norm) if dataset_map else None |
|
|
| |
| |
| if dataset_map: |
| if not dataset_entry: |
| skipped_missing_dataset += 1 |
| continue |
| entry = dataset_entry |
| sample["id"] = entry["id"] |
| sample["instruct_id"] = entry["instruct_id"] |
| sample["ability"] = entry["ability"] |
| sample["instruct_text"] = entry["instruct_text"] |
| else: |
| sample["id"] = sample.get("id", iid) |
| sample["instruct_id"] = sample.get("instruct_id", iid) |
| sample["instruct_text"] = sample.get("instruct_text") or sample.get("audio_content", "") |
|
|
| if not sample.get("id") or not sample.get("ability") or not sample.get("instruct_text"): |
| skipped_missing_required += 1 |
| continue |
|
|
| sample["final_audio_path"] = resolve_audio_path(root_dir, sample, dataset_entry) |
| if not str(sample.get("model_name", "")).strip(): |
| sample["model_name"] = "unknown_model" |
| ability = sample["ability"] |
|
|
| if normalize_id(sample["id"]) in processed_ids: |
| continue |
| if per_ability_counter[ability] >= max_per_ability: |
| continue |
|
|
| per_ability_counter[ability] += 1 |
| tasks.append(sample) |
|
|
| print(f"📊 Step 1 - After initial filtering: {len(tasks)} samples") |
| if skipped_missing_dataset: |
| print(f"📊 Skipped {skipped_missing_dataset} samples not found in dataset metadata") |
| if skipped_missing_required: |
| print(f"📊 Skipped {skipped_missing_required} samples missing id/ability/instruction") |
| print(f"📊 Step 2 - Before deduplication: {len(tasks)} samples") |
|
|
| tasks, dup_stats = remove_duplicates_from_tasks(tasks) |
| print(f"📊 Step 3 - After deduplication: {len(tasks)} samples (removed {dup_stats['total_duplicates']} duplicates)") |
|
|
| |
| |
| |
|
|
| if not tasks: |
| print("\n✅ No new samples to process, program ended.") |
| return scored_jsonl |
|
|
| ensure_dir(scored_jsonl.parent) |
| write_mode = "a" if scored_jsonl.exists() and not overwrite else "w" |
|
|
| print(f"\n🎯 Starting evaluation of {len(tasks)} samples...") |
| print("=" * 80) |
|
|
| with ( |
| scored_jsonl.open(write_mode, encoding="utf-8") as fout, |
| ThreadPoolExecutor(max_workers=concurrency) as executor, |
| tqdm(total=len(tasks), desc="Evaluating") as pbar, |
| ): |
| futures = { |
| executor.submit( |
| evaluate_one, |
| s, |
| root_dir, |
| prompts, |
| out_dir, |
| model_name, |
| max_retry, |
| sleep_between_retry, |
| api_key, |
| base_url, |
| fallback_score, |
| temperature, |
| top_p, |
| max_tokens, |
| verbose, |
| ): s |
| for s in tasks |
| } |
|
|
| for fut in as_completed(futures): |
| res = fut.result() |
| fout.write(json.dumps(res, ensure_ascii=False) + "\n") |
| fout.flush() |
| pbar.update(1) |
|
|
| print(f"\n✅ All processing complete, wrote {len(tasks)} new results -> {scored_jsonl}") |
| return scored_jsonl |
|
|
|
|
| |
|
|
| def get_ordered_abilities(): |
| ordered_abilities = [] |
| for big_cat in ["acoustic_attributes", "instruction", "role_play", "empathy"]: |
| ordered_abilities.extend(SUB_CATS[big_cat]) |
| return ordered_abilities |
|
|
|
|
| def analyze_scores(scored_jsonl: Path, out_dir: Path): |
| if pd is None or plt is None: |
| missing = [] |
| if pd is None: |
| missing.append("pandas") |
| if plt is None: |
| missing.append("matplotlib") |
| print(f"⚠️ Skipping score analysis/plots because missing dependency: {', '.join(missing)}") |
| print(" Install them if you need summary plots: pip install pandas matplotlib") |
| return |
|
|
| records = [ |
| json.loads(l) |
| for l in scored_jsonl.read_text(encoding="utf-8").splitlines() |
| if l.strip() |
| ] |
|
|
| rows, parse_fail_ids = [], [] |
| for rec in records: |
| score = safe_float(rec.get("gemini_score")) |
| if score is None: |
| parse_fail_ids.append(rec.get("id")) |
| continue |
| rows.append( |
| { |
| "id": rec.get("id"), |
| "model": rec.get("model_name"), |
| "ability": rec.get("ability"), |
| "score": score, |
| } |
| ) |
|
|
| print("len(rows):", len(rows)) |
| if not rows: |
| print("❌ No usable score data, script terminated.") |
| return |
|
|
| df = pd.DataFrame(rows) |
|
|
| BIG_CAT_W = 0.25 |
| WEIGHTS = {} |
|
|
| ap_comp = "acoustic_attributes/composite_properties" |
| ap_comp_w = BIG_CAT_W * 0.5 |
| ap_rem_each = (BIG_CAT_W - ap_comp_w) / (len(SUB_CATS["acoustic_attributes"]) - 1) |
|
|
| for ab in SUB_CATS["acoustic_attributes"]: |
| WEIGHTS[ab] = ap_comp_w if ab == ap_comp else ap_rem_each |
|
|
| for big in ("instruction", "role_play", "empathy"): |
| each = BIG_CAT_W / len(SUB_CATS[big]) |
| for ab in SUB_CATS[big]: |
| WEIGHTS[ab] = each |
|
|
| ordered_abilities = get_ordered_abilities() |
|
|
| print("\n================= Statistical Results =================") |
| for model, g in df.groupby("model"): |
| print(f"\nModel: {model}") |
| sub_means = g.groupby("ability")["score"].mean().to_dict() |
|
|
| weighted_sum, used_w = 0.0, 0.0 |
| for ab, w in WEIGHTS.items(): |
| if ab in sub_means: |
| weighted_sum += sub_means[ab] * w |
| used_w += w |
| weighted_mean = weighted_sum / used_w if used_w else float("nan") |
|
|
| print(f" Weighted overall average score: {weighted_mean:.2f} (weight coverage={used_w:.1%})") |
| print(" Major category average scores:") |
|
|
| for big_cat in ["acoustic_attributes", "instruction", "role_play", "empathy"]: |
| big_cat_weighted_sum = 0.0 |
| big_cat_used_w = 0.0 |
| big_cat_sample_count = 0 |
|
|
| for ability in SUB_CATS[big_cat]: |
| if ability in sub_means: |
| weight = WEIGHTS[ability] |
| big_cat_weighted_sum += sub_means[ability] * weight |
| big_cat_used_w += weight |
| big_cat_sample_count += g[g["ability"] == ability].shape[0] |
|
|
| if big_cat_used_w > 0: |
| big_cat_avg = big_cat_weighted_sum / big_cat_used_w |
| coverage = big_cat_used_w / BIG_CAT_W |
| print(f" {big_cat:<20s}: {big_cat_avg:.2f} (n={big_cat_sample_count}, coverage={coverage:.1%})") |
| else: |
| print(f" {big_cat:<20s}: N/A (no data)") |
|
|
| print(" Ability average scores:") |
| ability_means = g.groupby("ability")["score"].mean().to_dict() |
| for ab in ordered_abilities: |
| if ab in ability_means: |
| cnt = g[g["ability"] == ab].shape[0] |
| weight = WEIGHTS.get(ab, 0.0) |
| print(f" {ab:<40s}: {ability_means[ab]:.2f} (n={cnt}, weight={weight:.3f})") |
|
|
| print("\nStarting visualization...") |
| for model, g in df.groupby("model"): |
| model_dir = out_dir / model |
| ensure_dir(model_dir) |
|
|
| sub_means = g.groupby("ability")["score"].mean().to_dict() |
| big_cat_scores = [] |
| big_cat_labels = [] |
|
|
| for big_cat in ["acoustic_attributes", "instruction", "role_play", "empathy"]: |
| big_cat_weighted_sum = 0.0 |
| big_cat_used_w = 0.0 |
| for ability in SUB_CATS[big_cat]: |
| if ability in sub_means: |
| weight = WEIGHTS[ability] |
| big_cat_weighted_sum += sub_means[ability] * weight |
| big_cat_used_w += weight |
| if big_cat_used_w > 0: |
| big_cat_avg = big_cat_weighted_sum / big_cat_used_w |
| big_cat_scores.append(big_cat_avg) |
| big_cat_labels.append(big_cat.replace("_", " ").title()) |
|
|
| if big_cat_scores: |
| plt.figure(figsize=(10, 6)) |
| bars = plt.bar(range(len(big_cat_scores)), big_cat_scores) |
| plt.xticks(range(len(big_cat_labels)), big_cat_labels, rotation=45, ha="right") |
|
|
| for bar, score in zip(bars, big_cat_scores): |
| plt.text( |
| bar.get_x() + bar.get_width() / 2, |
| bar.get_height() + 0.01, |
| f"{score:.2f}", |
| ha="center", |
| va="bottom", |
| ) |
|
|
| w_sum = sum(sub_means[ab] * w for ab, w in WEIGHTS.items() if ab in sub_means) |
| w_used = sum(w for ab, w in WEIGHTS.items() if ab in sub_means) |
| w_avg = w_sum / w_used if w_used else float("nan") |
|
|
| plt.axhline(w_avg, linestyle="--", linewidth=2, label=f"Overall average: {w_avg:.2f}") |
| plt.ylabel("Average Score") |
| plt.title(f"{model} - Major Category Scores") |
| plt.legend() |
| plt.grid(True, alpha=0.3) |
| plt.tight_layout() |
| plt.savefig(model_dir / "major_category_scores.png", dpi=200, bbox_inches="tight") |
| plt.close() |
|
|
| ability_mean = g.groupby("ability")["score"].mean() |
| ordered_data, ordered_labels = [], [] |
| for ab in ordered_abilities: |
| if ab in ability_mean.index: |
| ordered_data.append(ability_mean[ab]) |
| ordered_labels.append(ab) |
|
|
| plt.figure(figsize=(8, 4 + 0.25 * len(ordered_data))) |
| y_pos = range(len(ordered_data)) |
| plt.barh(y_pos, ordered_data) |
| plt.yticks(y_pos, ordered_labels) |
|
|
| sub_means = g.groupby("ability")["score"].mean().to_dict() |
| w_sum = sum(sub_means[ab] * w for ab, w in WEIGHTS.items() if ab in sub_means) |
| w_used = sum(w for ab, w in WEIGHTS.items() if ab in sub_means) |
| w_avg = w_sum / w_used if w_used else float("nan") |
|
|
| plt.axvline(w_avg, linestyle="--", label="Weighted overall average") |
| plt.xlabel("Average score") |
| plt.title(f"{model} - Ability average scores") |
| plt.legend() |
| plt.tight_layout() |
| plt.savefig(model_dir / "ability_scores.png", dpi=200) |
| plt.close() |
|
|
| print(f"✅ Visualization complete, saved to folder: {out_dir}") |
|
|
| print("\n================= Parse Failure Information =================") |
| if parse_fail_ids: |
| print(f"Total of {len(parse_fail_ids)} records failed gemini_score parsing.") |
| else: |
| print("All records' gemini_score parsed successfully.") |
|
|
|
|
| |
|
|
| def main(): |
| root = "./data/examples" |
|
|
| parser = argparse.ArgumentParser( |
| description="Concurrent proxy scoring for voice results with forced score retries" |
| ) |
| parser.add_argument("--root_dir", default=f"{root}/model_res/en/wav", help="wav directory path") |
| parser.add_argument("--metadata_path", default=f"{root}/model_res/en/metadata.jsonl", help="metadata.jsonl path") |
| parser.add_argument("--prompts_dir", default="lalm_eval/eval_prompts/en", help="Directory containing prompt templates") |
| parser.add_argument("--out_dir", default=f"{root}/eval_res/en", help="Output root directory") |
|
|
| parser.add_argument("--temperature", type=float, default=1.0) |
| parser.add_argument("--top_p", type=float, default=0.7) |
| parser.add_argument("--max_tokens", type=int, default=4096) |
|
|
| parser.add_argument( |
| "--api_key", |
| default=os.environ.get("VSTYLE_API_KEY") or os.environ.get("OPENAI_API_KEY"), |
| help="Proxy API Key; defaults to VSTYLE_API_KEY or OPENAI_API_KEY", |
| ) |
| parser.add_argument( |
| "--base_url", |
| default=os.environ.get("VSTYLE_BASE_URL"), |
| help="Proxy base_url, e.g. https://xxx/v1; defaults to VSTYLE_BASE_URL", |
| ) |
| parser.add_argument( |
| "--model_name", |
| default=os.environ.get("VSTYLE_JUDGE_MODEL", "gemini-2.5-pro"), |
| help="Model name on proxy; defaults to VSTYLE_JUDGE_MODEL or gemini-2.5-pro", |
| ) |
|
|
| parser.add_argument( |
| "--max_retry_api", |
| type=int, |
| default=5, |
| help="Maximum number of retry attempts, capped at 5 in scoring logic", |
| ) |
| parser.add_argument( |
| "--sleep_between_retry", |
| type=int, |
| default=5, |
| help="Sleep time between retries (seconds)", |
| ) |
| parser.add_argument( |
| "--max_per_ability", |
| type=int, |
| default=100000, |
| help="Maximum number of evaluations per ability", |
| ) |
| parser.add_argument("--concurrency", type=int, default=4, help="Number of concurrent threads") |
| parser.add_argument("--overwrite", action="store_true", help="Overwrite existing results") |
| parser.add_argument( |
| "--fallback_score", |
| type=str, |
| default="3", |
| help="Fallback score if 5 attempts all fail; should be between 1 and 5", |
| ) |
| parser.add_argument( |
| "--dataset_metadata_path", |
| type=str, |
| default=None, |
| help="原始 VStyle 数据集的 metadata.jsonl 路径,用于补充 instruct_text", |
| ) |
| parser.add_argument("--verbose", action="store_true", help="Print raw judge replies during scoring") |
|
|
| args = parser.parse_args() |
|
|
| if not args.api_key: |
| parser.error("--api_key is required, or set VSTYLE_API_KEY / OPENAI_API_KEY") |
| if not args.base_url: |
| parser.error("--base_url is required, or set VSTYLE_BASE_URL") |
| if normalize_score_str(args.fallback_score) == "": |
| raise ValueError("--fallback_score must be a number between 1 and 5") |
|
|
| args.fallback_score = normalize_score_str(args.fallback_score) |
| print(f"args: {args}") |
|
|
| out_dir = Path(args.out_dir) |
| ensure_dir(out_dir) |
|
|
| scored_jsonl = eval_all_samples( |
| root_dir=Path(args.root_dir), |
| metadata_path=Path(args.metadata_path), |
| prompts_dir=Path(args.prompts_dir), |
| out_dir=out_dir, |
| max_per_ability=args.max_per_ability, |
| concurrency=args.concurrency, |
| overwrite=args.overwrite, |
| model_name=args.model_name, |
| max_retry=args.max_retry_api, |
| sleep_between_retry=args.sleep_between_retry, |
| api_key=args.api_key, |
| base_url=args.base_url, |
| fallback_score=args.fallback_score, |
| dataset_metadata_path=args.dataset_metadata_path, |
| temperature=args.temperature, |
| top_p=args.top_p, |
| max_tokens=args.max_tokens, |
| verbose=args.verbose, |
| ) |
|
|
| analyze_scores(scored_jsonl, out_dir) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|