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
634 lines
20 KiB
Python
634 lines
20 KiB
Python
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. 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.
|
|
import hashlib
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Any, Optional, Self
|
|
|
|
from scripts.tts_comparison_report.reporting.constants import TQDM_NCOLS
|
|
from scripts.tts_comparison_report.reporting.storage import BaseStorage
|
|
from tqdm import tqdm
|
|
|
|
|
|
_REQUIRED_SAMPLE_ID_KEYS: list[str] = [
|
|
"pred_audio_filepath",
|
|
"gt_text",
|
|
"gt_audio_filepath",
|
|
"context_audio_filepath",
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class BucketStructure:
|
|
"""Paths and naming conventions used to locate artifacts inside an evaluation bucket."""
|
|
|
|
eval_output_subdir: str = "results"
|
|
metrics_suffix: str = "_metrics_0.json"
|
|
metrics_filewise_suffix: str = "_filewise_metrics_0.json"
|
|
context_audio_dir: str = "audio/repeat_0"
|
|
context_audio_prefix: str = "context_audio_"
|
|
generated_audio_dir: str = "audio/repeat_0"
|
|
generated_audio_prefix: str = "predicted_audio_"
|
|
|
|
|
|
def _map_generated_to_context_name(
|
|
generated_name: str,
|
|
generated_prefix: str,
|
|
context_prefix: str,
|
|
) -> str:
|
|
suffix = generated_name.split(generated_prefix)[-1]
|
|
return f"{context_prefix}{suffix}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BenchmarkSampleMeta:
|
|
"""Metadata describing one generated sample within a benchmark."""
|
|
|
|
name: str
|
|
gt_text: str
|
|
context_path: Path
|
|
sample_id: str
|
|
|
|
@staticmethod
|
|
def _validate(item: dict[str, Any]) -> None:
|
|
for key in _REQUIRED_SAMPLE_ID_KEYS:
|
|
if key not in item:
|
|
raise ValueError(f"Missing required key '{key}' in filewise metrics item.")
|
|
|
|
@staticmethod
|
|
def _get_sample_id(item: dict[str, Any]) -> str:
|
|
parts = [item["gt_audio_filepath"], item["context_audio_filepath"]]
|
|
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
|
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
item: dict[str, Any],
|
|
context_audio_paths: dict[str, Path],
|
|
bucket_structure: BucketStructure,
|
|
) -> Self:
|
|
"""Create sample metadata from one filewise metrics item.
|
|
|
|
Args:
|
|
item: One entry from the filewise metrics JSON.
|
|
context_audio_paths: Mapping from context audio file name to its path.
|
|
bucket_structure: Bucket naming and path conventions.
|
|
|
|
Returns:
|
|
Sample metadata extracted from the given filewise metrics item.
|
|
|
|
Raises:
|
|
ValueError: If required keys are missing from the item.
|
|
KeyError: If the corresponding context audio file is not found.
|
|
"""
|
|
cls._validate(item)
|
|
|
|
name = Path(item["pred_audio_filepath"]).stem
|
|
|
|
key = _map_generated_to_context_name(
|
|
generated_name=name,
|
|
generated_prefix=bucket_structure.generated_audio_prefix,
|
|
context_prefix=bucket_structure.context_audio_prefix,
|
|
)
|
|
obj = cls(
|
|
name=name,
|
|
gt_text=item["gt_text"],
|
|
context_path=context_audio_paths[key],
|
|
sample_id=cls._get_sample_id(item),
|
|
)
|
|
return obj
|
|
|
|
|
|
def _collect_audio_paths(
|
|
root: Path,
|
|
prefix: str,
|
|
audio_paths: dict[str, Path],
|
|
storage: BaseStorage,
|
|
) -> None:
|
|
if not storage.exists(root):
|
|
raise FileNotFoundError(f"Missing audio directory: '{root}'.")
|
|
|
|
for p in storage.iter_dir(root):
|
|
if not p.stem.startswith(prefix) or p.suffix != ".wav":
|
|
continue
|
|
audio_paths[p.stem] = p
|
|
|
|
|
|
def _validate_audio_pairs(
|
|
context_audio_paths: dict[str, Path],
|
|
generated_audio_paths: dict[str, Path],
|
|
bucket_structure: BucketStructure,
|
|
) -> None:
|
|
for name in generated_audio_paths:
|
|
key = _map_generated_to_context_name(
|
|
generated_name=name,
|
|
generated_prefix=bucket_structure.generated_audio_prefix,
|
|
context_prefix=bucket_structure.context_audio_prefix,
|
|
)
|
|
if key not in context_audio_paths:
|
|
raise ValueError(f"Missing context audio: '{key}'.")
|
|
|
|
|
|
@dataclass
|
|
class BenchmarkData:
|
|
"""Artifacts and loaded data associated with one evaluation benchmark."""
|
|
|
|
name: str
|
|
metrics_path: Optional[Path] = None
|
|
filewise_metrics_path: Optional[Path] = None
|
|
generated_audio_paths: dict[str, Path] = field(default_factory=dict)
|
|
context_audio_paths: dict[str, Path] = field(default_factory=dict)
|
|
|
|
metrics: Optional[dict[str, float]] = None
|
|
filewise_metrics: Optional[list[dict[str, Any]]] = None
|
|
|
|
@classmethod
|
|
def from_storage(
|
|
cls,
|
|
benchmark_name: str,
|
|
benchmark_path: Path,
|
|
bucket_structure: BucketStructure,
|
|
check_audio: bool,
|
|
storage: BaseStorage,
|
|
) -> Self:
|
|
"""Create benchmark data by discovering benchmark artifacts in storage.
|
|
|
|
Args:
|
|
benchmark_name: Name of the benchmark.
|
|
benchmark_path: Path to the benchmark directory inside the evaluation bucket.
|
|
bucket_structure: Bucket naming and path conventions.
|
|
check_audio: Whether generated audio files should also be discovered.
|
|
storage: Storage backend used to access local or remote files.
|
|
|
|
Returns:
|
|
Benchmark data initialized with discovered artifact paths.
|
|
|
|
Raises:
|
|
FileNotFoundError: If required metrics files are missing, audio directories
|
|
are missing, or expected audio files cannot be found.
|
|
ValueError: If generated audio files do not have matching context audio files.
|
|
"""
|
|
obj = cls(name=benchmark_name)
|
|
|
|
path = benchmark_path / f"{benchmark_name}{bucket_structure.metrics_suffix}"
|
|
if not storage.exists(path):
|
|
raise FileNotFoundError(f"Missing metrics file: '{path}'.")
|
|
obj.metrics_path = path
|
|
|
|
path = benchmark_path / f"{benchmark_name}{bucket_structure.metrics_filewise_suffix}"
|
|
if not storage.exists(path):
|
|
raise FileNotFoundError(f"Missing filewise metrics file: '{path}'.")
|
|
obj.filewise_metrics_path = path
|
|
|
|
if check_audio:
|
|
_collect_audio_paths(
|
|
root=benchmark_path / bucket_structure.context_audio_dir,
|
|
prefix=bucket_structure.context_audio_prefix,
|
|
audio_paths=obj.context_audio_paths,
|
|
storage=storage,
|
|
)
|
|
if not obj.context_audio_paths:
|
|
raise FileNotFoundError(
|
|
f"No context audio files were found in '{benchmark_path / bucket_structure.context_audio_dir}'. "
|
|
"The bucket structure likely differs from the one specified in 'BucketStructure'."
|
|
)
|
|
_collect_audio_paths(
|
|
root=benchmark_path / bucket_structure.generated_audio_dir,
|
|
prefix=bucket_structure.generated_audio_prefix,
|
|
audio_paths=obj.generated_audio_paths,
|
|
storage=storage,
|
|
)
|
|
if not obj.generated_audio_paths:
|
|
raise FileNotFoundError(
|
|
f"No generated audio files were found in '{benchmark_path / bucket_structure.generated_audio_dir}'. "
|
|
"The bucket structure likely differs from the one specified in 'BucketStructure'."
|
|
)
|
|
_validate_audio_pairs(
|
|
context_audio_paths=obj.context_audio_paths,
|
|
generated_audio_paths=obj.generated_audio_paths,
|
|
bucket_structure=bucket_structure,
|
|
)
|
|
return obj
|
|
|
|
def load_metrics(self, storage: BaseStorage) -> None:
|
|
"""Load aggregated benchmark metrics from storage.
|
|
|
|
Args:
|
|
storage: Storage instance used to read the metrics file.
|
|
|
|
Raises:
|
|
TypeError: If the metrics file does not contain a JSON object.
|
|
"""
|
|
if self.metrics_path is None:
|
|
return
|
|
|
|
data = storage.read_json(self.metrics_path)
|
|
|
|
if not isinstance(data, dict):
|
|
raise TypeError(f"Metrics file must contain a JSON object: '{self.metrics_path}'.")
|
|
|
|
self.metrics = data
|
|
|
|
def load_filewise_metrics(self, storage: BaseStorage) -> None:
|
|
"""Load filewise benchmark metrics from storage.
|
|
|
|
Args:
|
|
storage: Storage instance used to read the filewise metrics file.
|
|
|
|
Raises:
|
|
TypeError: If the filewise metrics file does not contain a JSON array.
|
|
"""
|
|
if self.filewise_metrics_path is None:
|
|
return
|
|
|
|
data = storage.read_json(self.filewise_metrics_path)
|
|
|
|
if not isinstance(data, list):
|
|
raise TypeError(f"Filewise metrics file must contain a JSON array: '{self.filewise_metrics_path}'.")
|
|
|
|
self.filewise_metrics = data
|
|
|
|
|
|
def _validate_numeric_metric_value(
|
|
value: Any,
|
|
metric_name: str,
|
|
context: str,
|
|
) -> float:
|
|
if not isinstance(value, (int, float)):
|
|
raise TypeError(
|
|
f"Metric '{metric_name}' in {context} must be numeric, "
|
|
f"but got value {value!r} of type {type(value).__name__}."
|
|
)
|
|
return float(value)
|
|
|
|
|
|
@dataclass
|
|
class BucketData:
|
|
"""Evaluation bucket metadata and loaded metric data."""
|
|
|
|
name: str
|
|
path: Path
|
|
configuration_str: Optional[str] = None
|
|
benchmarks: dict[str, BenchmarkData] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def from_storage(
|
|
cls,
|
|
bucket_name: str,
|
|
bucket_path: Path,
|
|
bucket_structure: BucketStructure,
|
|
benchmark_names: tuple[str, ...],
|
|
check_audio: bool,
|
|
storage: BaseStorage,
|
|
) -> Self:
|
|
"""Create bucket data by discovering benchmark artifacts in storage.
|
|
|
|
Args:
|
|
bucket_name: Display name of the bucket, typically the name of the model it belongs to.
|
|
bucket_path: Path to the bucket root directory.
|
|
bucket_structure: Bucket naming and path conventions.
|
|
benchmark_names: Benchmark names expected in the bucket.
|
|
check_audio: Whether generated audio files should also be discovered.
|
|
storage: Storage instance used to access local or remote files.
|
|
|
|
Returns:
|
|
Bucket data initialized with discovered benchmark artifacts.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the expected results directory is missing.
|
|
"""
|
|
obj = cls(name=bucket_name, path=bucket_path)
|
|
results_path = bucket_path / bucket_structure.eval_output_subdir
|
|
|
|
if not storage.exists(results_path):
|
|
raise FileNotFoundError(f"Missing results directory: '{results_path}'.")
|
|
|
|
for benchmark_path in storage.iter_dir(results_path, only_dirs=True):
|
|
if len(obj.benchmarks) == len(benchmark_names):
|
|
break
|
|
|
|
dir_name = benchmark_path.name
|
|
name = next((n for n in benchmark_names if dir_name == n or dir_name.endswith(f"_{n}")), None)
|
|
|
|
if name is None:
|
|
continue
|
|
|
|
obj.benchmarks[name] = BenchmarkData.from_storage(
|
|
benchmark_name=name,
|
|
benchmark_path=benchmark_path,
|
|
bucket_structure=bucket_structure,
|
|
check_audio=check_audio,
|
|
storage=storage,
|
|
)
|
|
if obj.configuration_str is None:
|
|
suffix = f"_{name}"
|
|
obj.configuration_str = dir_name[: -len(suffix)]
|
|
|
|
return obj
|
|
|
|
def load_metrics(
|
|
self,
|
|
storage: BaseStorage,
|
|
show_pbar: bool = False,
|
|
) -> None:
|
|
"""Load aggregated and filewise metrics for all discovered benchmarks.
|
|
|
|
Args:
|
|
storage: Storage instance used to read metrics files.
|
|
show_pbar: Whether to display a progress bar while loading metrics.
|
|
"""
|
|
pbar = tqdm(total=len(self.benchmarks), ncols=TQDM_NCOLS) if show_pbar else None
|
|
|
|
for benchmark_data in self.benchmarks.values():
|
|
benchmark_data.load_metrics(storage)
|
|
benchmark_data.load_filewise_metrics(storage)
|
|
|
|
if pbar:
|
|
pbar.update(1)
|
|
|
|
if pbar:
|
|
pbar.close()
|
|
|
|
def get_metric_avg_value(
|
|
self,
|
|
metric_name: str,
|
|
benchmark_name: str,
|
|
) -> Optional[float]:
|
|
"""Return the aggregated value of a metric for one benchmark.
|
|
|
|
Args:
|
|
metric_name: Name of the metric to retrieve.
|
|
benchmark_name: Name of the benchmark.
|
|
|
|
Returns:
|
|
Aggregated metric value, or `None` if the metric is not present.
|
|
|
|
Raises:
|
|
ValueError: If the benchmark is unknown or metrics are not loaded.
|
|
TypeError: If the metric value is not numeric.
|
|
"""
|
|
if benchmark_name not in self.benchmarks:
|
|
raise ValueError(f"Unknown benchmark: '{benchmark_name}'.")
|
|
|
|
metrics = self.benchmarks[benchmark_name].metrics
|
|
|
|
if metrics is None:
|
|
raise ValueError(f"Metrics not loaded for benchmark: '{benchmark_name}'.")
|
|
|
|
if metric_name not in metrics:
|
|
return None
|
|
|
|
value = _validate_numeric_metric_value(
|
|
value=metrics[metric_name],
|
|
metric_name=metric_name,
|
|
context=f"averaged metrics for benchmark '{benchmark_name}'",
|
|
)
|
|
return value
|
|
|
|
def _get_metric_stats(
|
|
self,
|
|
metric_name: str,
|
|
benchmark_name: str,
|
|
) -> list[float]:
|
|
if benchmark_name not in self.benchmarks:
|
|
raise ValueError(f"Unknown benchmark: '{benchmark_name}'.")
|
|
|
|
items = self.benchmarks[benchmark_name].filewise_metrics
|
|
|
|
if items is None or not items:
|
|
raise ValueError(f"Filewise metrics not loaded for benchmark: '{benchmark_name}'.")
|
|
|
|
output = []
|
|
validation_context = f"filewise metrics for benchmark '{benchmark_name}'"
|
|
|
|
for item in items:
|
|
if metric_name not in item:
|
|
continue
|
|
|
|
value = _validate_numeric_metric_value(
|
|
value=item[metric_name],
|
|
metric_name=metric_name,
|
|
context=validation_context,
|
|
)
|
|
output.append(value)
|
|
|
|
if not output:
|
|
raise ValueError(f"Unknown or empty metric '{metric_name}' for benchmark '{benchmark_name}'.")
|
|
|
|
return output
|
|
|
|
def _aggregate_metric_stats(self, metric_name: str) -> list[float]:
|
|
output = []
|
|
|
|
for benchmark_name in self.benchmarks:
|
|
output.extend(self._get_metric_stats(metric_name, benchmark_name))
|
|
|
|
if not output:
|
|
raise ValueError(f"Unknown or empty aggregated metric '{metric_name}'.")
|
|
|
|
return output
|
|
|
|
def get_metric_samples(
|
|
self,
|
|
metric_name: str,
|
|
benchmark_name: Optional[str] = None,
|
|
) -> list[float]:
|
|
"""Return filewise samples for a metric from one or all benchmarks.
|
|
|
|
Args:
|
|
metric_name: Name of the metric to retrieve.
|
|
benchmark_name: Benchmark name. If omitted, samples are aggregated
|
|
across all benchmarks.
|
|
|
|
Returns:
|
|
List of numeric metric samples.
|
|
|
|
Raises:
|
|
ValueError: If the benchmark is unknown, filewise metrics are not loaded,
|
|
or the metric is missing.
|
|
TypeError: If any metric value is not numeric.
|
|
"""
|
|
if benchmark_name is None:
|
|
return self._aggregate_metric_stats(metric_name)
|
|
return self._get_metric_stats(metric_name, benchmark_name)
|
|
|
|
def get_benchmark_audio_paths(self, benchmark_name: str) -> dict[str, Path]:
|
|
"""Return generated audio file paths for a benchmark.
|
|
|
|
Args:
|
|
benchmark_name: Name of the benchmark.
|
|
|
|
Returns:
|
|
Mapping from sample name to generated audio path.
|
|
|
|
Raises:
|
|
ValueError: If the benchmark is unknown or audio paths are not loaded.
|
|
"""
|
|
if benchmark_name not in self.benchmarks:
|
|
raise ValueError(f"Unknown benchmark: '{benchmark_name}'.")
|
|
|
|
paths = self.benchmarks[benchmark_name].generated_audio_paths
|
|
|
|
if not paths:
|
|
raise ValueError(f"Generated audio paths not loaded for benchmark: '{benchmark_name}'.")
|
|
|
|
return paths
|
|
|
|
def get_benchmark_sample_meta(
|
|
self,
|
|
benchmark_name: str,
|
|
bucket_structure: BucketStructure,
|
|
) -> dict[str, BenchmarkSampleMeta]:
|
|
"""Return sample metadata for a benchmark derived from filewise metrics.
|
|
|
|
Args:
|
|
benchmark_name: Name of the benchmark.
|
|
bucket_structure: Bucket naming and path conventions used to resolve
|
|
matching context audio files.
|
|
|
|
Returns:
|
|
Mapping from sample name to benchmark sample metadata.
|
|
|
|
Raises:
|
|
ValueError: If the benchmark is unknown, filewise metrics are not loaded,
|
|
or context audio paths are not loaded.
|
|
KeyError: If a matching context audio file cannot be found for a sample.
|
|
"""
|
|
if benchmark_name not in self.benchmarks:
|
|
raise ValueError(f"Unknown benchmark: '{benchmark_name}'.")
|
|
|
|
items = self.benchmarks[benchmark_name].filewise_metrics
|
|
|
|
if not items:
|
|
raise ValueError(f"Filewise metrics not loaded for benchmark: '{benchmark_name}'.")
|
|
|
|
paths = self.benchmarks[benchmark_name].context_audio_paths
|
|
|
|
if not paths:
|
|
raise ValueError(f"Context audio paths not loaded for benchmark: '{benchmark_name}'.")
|
|
|
|
output = {}
|
|
|
|
for item in items:
|
|
meta = BenchmarkSampleMeta.create(
|
|
item=item,
|
|
context_audio_paths=paths,
|
|
bucket_structure=bucket_structure,
|
|
)
|
|
output[meta.name] = meta
|
|
|
|
return output
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TaskInfo:
|
|
"""Task identifiers and derived Jira link information used in reports."""
|
|
|
|
task_id: str
|
|
jira_id: str
|
|
jira_url: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExpirationInfo:
|
|
"""Formatted expiration metadata used in reports and S3 artifact paths."""
|
|
|
|
timestamp: int
|
|
path_str: str
|
|
user_str: str
|
|
|
|
|
|
class Winner(str, Enum):
|
|
"""Possible outcomes of a statistical comparison between baseline and candidate."""
|
|
|
|
baseline = "baseline"
|
|
candidate = "candidate"
|
|
tie = "tie"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StatTestResult:
|
|
"""Result of a statistical comparison for a single metric."""
|
|
|
|
metric_name: str
|
|
winner: Winner
|
|
alternative: str
|
|
p_value: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StatTestAnalysisInfo:
|
|
"""Summary information used to describe statistical test outcomes in reports."""
|
|
|
|
winner: Optional[str]
|
|
advantages: Optional[str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvalResult:
|
|
"""Evaluation results for one report section, including tables, analysis, and plot."""
|
|
|
|
metrics_table_row: list[str | float]
|
|
stat_test_table_row: list[str | float]
|
|
stat_tests_analysis_info: StatTestAnalysisInfo
|
|
box_plots: BytesIO
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelConfiguration:
|
|
"""Configuration strings associated with the baseline and candidate models."""
|
|
|
|
baseline: str
|
|
candidate: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvalArtifacts:
|
|
"""Prepared evaluation results, configuration, and comparison metadata used to render reports."""
|
|
|
|
configuration: ModelConfiguration
|
|
summary: EvalResult
|
|
benchmarks: dict[str, EvalResult]
|
|
is_self_comparison: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UploadedBoxPlotsInfo:
|
|
"""S3 URLs of uploaded summary and benchmark-level box plot images."""
|
|
|
|
summary_url: str
|
|
benchmark_urls: dict[str, str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AudioPair:
|
|
"""Matched context, baseline, and candidate audio files for one sample."""
|
|
|
|
context_path: Path
|
|
baseline_path: Path
|
|
candidate_path: Path
|
|
text: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UploadedAudioPairInfo:
|
|
"""Uploaded context, baseline, and candidate audio URLs for one sample."""
|
|
|
|
context_url: str
|
|
baseline_url: str
|
|
candidate_url: str
|
|
text: str
|