Skip to content

Commit d9bdff0

Browse files
committed
fix(audio): harden AudioBench and MMAU evaluation
Signed-off-by: Piotr Żelasko <pzelasko@nvidia.com>
1 parent 9c34cd1 commit d9bdff0

8 files changed

Lines changed: 148 additions & 40 deletions

File tree

nemo_skills/dataset/audiobench/nonjudge/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@
2626
# Evaluation settings
2727
EVAL_ARGS = "++eval_type=audio ++eval_config.normalization_mode=audiobench"
2828

29-
# Generation settings - OpenAI format for audio-language models
30-
GENERATION_ARGS = "++prompt_format=openai ++enable_audio=true"
29+
# Generation settings - OpenAI format for audio-language models.
30+
# Soft-fail keeps one over-limit or malformed audio request from aborting an
31+
# entire chunk; audio metrics count the empty generation as incorrect.
32+
GENERATION_ARGS = "++prompt_format=openai ++enable_audio=true ++server.enable_soft_fail=true"

nemo_skills/dataset/audiobench/prepare.py

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""
2626

2727
import argparse
28+
import copy
2829
import json
2930
import os
3031
import shutil
@@ -155,12 +156,21 @@ def create_manifest_entry(
155156
Manifest entry dict with proper format for nemo-skills
156157
"""
157158
instruction = sample.get("instruction", sample.get("text", "Process the audio"))
159+
if isinstance(instruction, dict):
160+
try:
161+
instruction = instruction["text"]
162+
except KeyError as exc:
163+
raise ValueError(
164+
f"Instruction dict missing required 'text' field for {dataset_name} sample {sample_id}: {instruction}"
165+
) from exc
166+
if not isinstance(instruction, str):
167+
raise ValueError(f"Instruction must be a string for {dataset_name} sample {sample_id}: {instruction!r}")
158168
reference = sample.get("reference", sample.get("answer", ""))
159169
task_type = sample.get("task_type", "unknown")
160170

161-
# Create absolute audio path with /data/ prefix for cluster deployment
162-
# Format: /data/audiobench/{category}/audio/{dataset_name}/{filename}
163-
audio_rel_path = f"/data/audiobench/{category}/audio/{dataset_name}/{audio_filename}"
171+
# Paths are resolved relative to the manifest directory by the inference code.
172+
# The combined category manifest lives at audiobench/{category}/test.jsonl.
173+
audio_rel_path = f"audio/{dataset_name}/{audio_filename}"
164174

165175
# Create audio metadata (both singular and plural forms for compatibility)
166176
audio_metadata = {"path": audio_rel_path, "duration": duration}
@@ -203,6 +213,28 @@ def create_manifest_entry(
203213
return entry
204214

205215

216+
def make_dataset_manifest_entry(entry: Dict) -> Dict:
217+
"""Adjust category-relative audio paths for per-dataset manifests."""
218+
entry = copy.deepcopy(entry)
219+
220+
def rewrite(path: str) -> str:
221+
return f"../{path}" if path.startswith("audio/") else path
222+
223+
if isinstance(entry.get("audio_path"), list):
224+
entry["audio_path"] = [rewrite(path) for path in entry["audio_path"]]
225+
elif isinstance(entry.get("audio_path"), str):
226+
entry["audio_path"] = rewrite(entry["audio_path"])
227+
228+
for message in entry.get("messages", []):
229+
if "audio" in message and "path" in message["audio"]:
230+
message["audio"]["path"] = rewrite(message["audio"]["path"])
231+
for audio in message.get("audios", []):
232+
if "path" in audio:
233+
audio["path"] = rewrite(audio["path"])
234+
235+
return entry
236+
237+
206238
def process_dataset(
207239
dataset_name: str,
208240
output_dir: Path,
@@ -473,7 +505,7 @@ def process_dataset(
473505
manifest_path = dataset_dir / f"{split}.jsonl"
474506
with open(manifest_path, "w", encoding="utf-8") as f:
475507
for entry in manifest_entries:
476-
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
508+
f.write(json.dumps(make_dataset_manifest_entry(entry), ensure_ascii=False) + "\n")
477509

478510
print(f"✓ Saved {successful} samples to {manifest_path}")
479511
if failed > 0:
@@ -566,6 +598,7 @@ def main():
566598

567599
total_samples = 0
568600
total_datasets = 0
601+
combined_entries = {"judge": [], "nonjudge": []}
569602

570603
for name in target_datasets:
571604
# Normalize dataset name: allow passing without _test suffix
@@ -575,23 +608,33 @@ def main():
575608
if f"{dataset_name}_test" in JUDGE_DATASETS or f"{dataset_name}_test" in NONJUDGE_DATASETS:
576609
dataset_name = f"{dataset_name}_test"
577610

578-
# Determine category for logging
579-
category = "judge" if name in JUDGE_DATASETS else "nonjudge"
611+
if dataset_name in JUDGE_DATASETS:
612+
category = "judge"
613+
elif dataset_name in NONJUDGE_DATASETS:
614+
category = "nonjudge"
615+
else:
616+
raise ValueError(f"Unsupported AudioBench dataset name: {name}")
617+
618+
num_samples, manifest_entries = process_dataset(
619+
dataset_name=dataset_name,
620+
output_dir=output_dir,
621+
save_audio=args.save_audio,
622+
split=args.split,
623+
max_samples=args.max_samples,
624+
)
625+
total_samples += num_samples
626+
total_datasets += 1
627+
combined_entries[category].extend(manifest_entries)
628+
print(f"✓ Completed {dataset_name}: {num_samples} samples")
580629

581-
try:
582-
num_samples, _ = process_dataset(
583-
dataset_name=dataset_name,
584-
output_dir=output_dir,
585-
save_audio=args.save_audio,
586-
split=args.split,
587-
max_samples=args.max_samples,
588-
)
589-
total_samples += num_samples
590-
total_datasets += 1
591-
print(f"✓ Completed {dataset_name}: {num_samples} samples")
592-
except Exception as e:
593-
print(f"✗ Failed {dataset_name}: {e}")
630+
for category, entries in combined_entries.items():
631+
if not entries:
594632
continue
633+
combined_manifest = output_dir / category / f"{args.split}.jsonl"
634+
with open(combined_manifest, "w", encoding="utf-8") as f:
635+
for entry in entries:
636+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
637+
print(f"✓ Saved combined {category} manifest with {len(entries)} samples to {combined_manifest}")
595638

596639
print("\n" + "=" * 60)
597640
print("AudioBench Preparation Summary")

nemo_skills/dataset/mmau-pro/closed_form/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
# limitations under the License.
1414
METRICS_TYPE = "mmau_pro_closed_form"
1515
SCORE_MODULE = "nemo_skills.evaluation.metrics.mmau_pro_metrics"
16-
GENERATION_ARGS = "++prompt_format=openai ++enable_audio=true"
17-
EVAL_ARGS = "++eval_type=mmau-pro"
16+
GENERATION_ARGS = "++prompt_format=openai ++enable_audio=true ++server.enable_soft_fail=true"
1817

1918
# NVEmbed judge configuration for closed-form evaluation
2019
JUDGE_PIPELINE_ARGS = {

nemo_skills/dataset/mmau-pro/prepare.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,11 @@ def format_entry(entry, with_audio=False):
8686
audio_path = entry["audio_path"]
8787

8888
if isinstance(audio_path, list) and audio_path:
89-
user_message["audios"] = [{"path": path, "duration": 10.0} for path in audio_path]
89+
formatted_entry["audio_path"] = [f"../{path}" for path in audio_path]
90+
user_message["audios"] = [{"path": path, "duration": 10.0} for path in formatted_entry["audio_path"]]
9091
elif isinstance(audio_path, str):
91-
user_message["audio"] = {"path": audio_path, "duration": 10.0}
92+
formatted_entry["audio_path"] = f"../{audio_path}"
93+
user_message["audio"] = {"path": formatted_entry["audio_path"], "duration": 10.0}
9294

9395
formatted_entry["messages"] = [user_message]
9496
return formatted_entry

nemo_skills/evaluation/evaluator/audio.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,12 +735,28 @@ def eval_audio(cfg):
735735
asyncio.run(evaluator.eval_full())
736736

737737

738+
def coerce_text_field(value: Any) -> str:
739+
"""Convert loose AudioBench text/reference fields to plain text."""
740+
if value is None:
741+
return ""
742+
if isinstance(value, str):
743+
return value.strip()
744+
if isinstance(value, dict):
745+
for key in ("text", "expected_answer", "answer", "transcript", "reference"):
746+
if key in value and value[key] is not None:
747+
return coerce_text_field(value[key])
748+
return " ".join(coerce_text_field(item) for item in value.values() if item is not None).strip()
749+
if isinstance(value, (list, tuple)):
750+
return " ".join(coerce_text_field(item) for item in value if item is not None).strip()
751+
return str(value).strip()
752+
753+
738754
def evaluate_sample(sample: dict[str, Any], config: AudioEvaluatorConfig) -> dict[str, Any]:
739755
"""Evaluate single sample based on task_type. Returns dict of updates to merge."""
740756
updates = {}
741757
task_type = sample.get("task_type", "unknown")
742-
generation = sample["generation"].strip()
743-
expected_answer = sample.get("expected_answer", "").strip()
758+
generation = coerce_text_field(sample.get("generation", ""))
759+
expected_answer = coerce_text_field(sample.get("expected_answer", ""))
744760

745761
# Extract ASR text from generation
746762
# E.g Qwen ASR uses <asr_text> tags to indicate the ASR text

nemo_skills/evaluation/evaluator/mmau_pro.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ def eval_mmau_pro(cfg):
5656

5757
def evaluate_instruction_following_sample(sample: dict[str, Any]) -> dict[str, Any]:
5858
"""Evaluate a single instruction following sample."""
59+
if sample is None:
60+
LOG.warning("Encountered null MMAU-Pro sample; marking it incorrect")
61+
return {"is_correct": False, "error": "null_sample"}
62+
5963
sample = sample.copy()
6064
generation = sample.get("generation", "").strip()
6165

nemo_skills/evaluation/evaluator/nvembed_judge.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,18 @@ def install_packages():
4242
"""Install required packages for NVEmbed evaluation."""
4343
LOG.info("Installing required packages...")
4444
subprocess.run(
45-
["pip", "install", "-q", "datasets", "einops", "transformers==4.42.4", "tqdm"],
45+
[
46+
sys.executable,
47+
"-m",
48+
"pip",
49+
"install",
50+
"-q",
51+
"numpy<2",
52+
"datasets",
53+
"einops",
54+
"transformers==4.42.4",
55+
"tqdm",
56+
],
4657
check=True,
4758
capture_output=True,
4859
text=True,
@@ -120,15 +131,23 @@ def evaluate_sample_with_nvembed(sample: dict[str, Any], model_name: str = "nvid
120131
if "nvembed_confidence" in sample:
121132
return sample
122133

123-
generation = sample.get("generation", "").strip()
134+
generation_value = sample.get("generation", "")
135+
generation = generation_value.strip() if isinstance(generation_value, str) else ""
124136
choices = sample.get("choices", [])
125137
expected_answer = sample.get("expected_answer", "")
126138

127-
# Fail fast if data is malformed - this indicates a pipeline error
139+
# Empty model outputs are valid failed predictions. Keep malformed-data
140+
# checks strict, but do not let one blank generation abort the whole eval.
128141
if not generation:
129-
raise ValueError(
130-
f"Sample missing generation field or has empty generation. Sample ID: {sample.get('id', 'unknown')}"
142+
sample.update(
143+
{
144+
"nvembed_matched_choice": "",
145+
"nvembed_confidence": 0.0,
146+
"is_correct": False,
147+
"nvembed_error": "empty_generation",
148+
}
131149
)
150+
return sample
132151

133152
if not choices:
134153
raise ValueError(

nemo_skills/pipeline/judges/nvembed_judge.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""NVEmbed judge implementation for embedding-based similarity matching."""
1616

1717
import logging
18+
import shlex
1819

1920
from nemo_skills.pipeline.utils import add_task
2021
from nemo_skills.pipeline.utils.generation import get_remaining_jobs
@@ -35,6 +36,8 @@ def create_judge_tasks(
3536
judge_server_gpus,
3637
judge_server_nodes,
3738
partition,
39+
account,
40+
judge_container,
3841
run_after,
3942
reuse_code_exp,
4043
reuse_code,
@@ -59,6 +62,8 @@ def create_judge_tasks(
5962
judge_server_gpus: Number of GPUs for judge
6063
judge_server_nodes: Number of nodes for judge
6164
partition: SLURM partition
65+
account: SLURM account
66+
judge_container: Container to use for the judge
6267
run_after: Dependencies to run after
6368
reuse_code_exp: Experiment to reuse code from
6469
reuse_code: Whether to reuse code
@@ -95,32 +100,50 @@ def create_judge_tasks(
95100
return []
96101

97102
# Build command to run NVEmbed judge script
98-
script_args = [f"--output-dir {output_dir_path}"]
103+
script_args = ["--output-dir", str(output_dir_path)]
99104

100105
if input_file is None:
101-
input_dir = judge_pipeline_args.get("input_dir")
102-
script_args.append(f"--input-dir {input_dir}")
103-
script_args.append(f"--num-seeds {num_seeds}")
106+
try:
107+
input_dir = judge_pipeline_args["input_dir"]
108+
except KeyError as exc:
109+
raise ValueError(
110+
"NVEmbed judge requires judge_pipeline_args['input_dir'] when input_file is unset"
111+
) from exc
112+
if input_dir is None:
113+
raise ValueError("NVEmbed judge requires a non-null input_dir when input_file is unset")
114+
script_args.extend(["--input-dir", str(input_dir)])
115+
script_args.extend(["--num-seeds", str(num_seeds)])
104116
else:
105-
script_args.append(f"--input-file {input_file}")
117+
script_args.extend(["--input-file", str(input_file)])
106118

107119
# Add skip-existing flag unless rerun_done is set
108120
if not rerun_done:
109121
script_args.append("--skip-existing")
110-
111-
run_cmd = f"python3 -I /nemo_run/code/nemo_skills/evaluation/evaluator/nvembed_judge.py {' '.join(script_args)}"
122+
script_args.append("--skip-install")
123+
124+
run_cmd = (
125+
"NVEMBED_DEPS_DIR=/tmp/nvembed_deps_${SLURM_JOB_ID:-$$} && "
126+
'mkdir -p "$NVEMBED_DEPS_DIR" && '
127+
'python3 -m pip install -q --upgrade --target "$NVEMBED_DEPS_DIR" '
128+
"'numpy==1.26.4' datasets einops transformers==4.42.4 tqdm && "
129+
'export PYTHONPATH="$NVEMBED_DEPS_DIR:${PYTHONPATH:-}" && '
130+
"export HF_HUB_OFFLINE=0 TRANSFORMERS_OFFLINE=0 HF_DATASETS_OFFLINE=0 && "
131+
"python3 /nemo_run/code/nemo_skills/evaluation/evaluator/nvembed_judge.py "
132+
f"{' '.join(shlex.quote(arg) for arg in script_args)}"
133+
)
112134

113135
# Create task with GPU support for NVEmbed
114136
judge_task = add_task(
115137
exp,
116138
cmd=run_cmd,
117139
task_name=f"{expname}-{benchmark}-nvembed-judge",
118140
log_dir=log_dir + "/judge",
119-
container=cluster_config["containers"]["vllm"],
141+
container=judge_container or cluster_config["containers"]["vllm"],
120142
cluster_config=cluster_config,
121143
num_gpus=judge_server_gpus or 1,
122144
num_nodes=judge_server_nodes or 1,
123145
partition=partition,
146+
account=account,
124147
run_after=run_after,
125148
reuse_code_exp=reuse_code_exp,
126149
reuse_code=reuse_code,

0 commit comments

Comments
 (0)