feat(diarization): добавлена проверка переносимого бенчмарка
- Зачем: - новый агент должен проверить приватный набор до дорогого CPU-прогона. - Что: - добавлены режим validate-only, относительные пути и ранняя проверка зависимостей. - описано воспроизведение; временные handoff исключены из Git. - Проверка: - .venv/bin/pytest -q: 334 passed, 2 skipped. - ruff check, ruff format --check и git diff --check: успешно.
This commit is contained in:
1 parent
1e14f2687a
commit
5b5fd535d6
5 files changed
+268
-19
No files matched your search
@@ -7,3 +7,4 @@ dist/
|
|||||||
.codex
|
.codex
|
||||||
.qwen/
|
.qwen/
|
||||||
tmp
|
tmp
|
||||||
|
.scratch/handoffs/
|
||||||
@@ -74,6 +74,41 @@ WeSpeaker/TitaNet на трёх пятиминутных русскоязычн
|
|||||||
Во всех 108 ячейках совпала последовательность 1853 распознанных слов после
|
Во всех 108 ячейках совпала последовательность 1853 распознанных слов после
|
||||||
удаления Unicode-пробелов. Инвариантность текста не нарушена.
|
удаления Unicode-пробелов. Инвариантность текста не нарушена.
|
||||||
|
|
||||||
|
## Воспроизведение
|
||||||
|
|
||||||
|
Стенд [`diarization_calibration.py`](../../scripts/benchmarks/diarization_calibration.py)
|
||||||
|
закрепляет корпус и матрицу задачи #25. Файл manifest, ASR-sidecar, интервалы и
|
||||||
|
полный результат содержат приватные данные и хранятся отдельно от Git.
|
||||||
|
Относительные пути в manifest считаются от каталога самого файла, поэтому
|
||||||
|
приватный набор можно перенести на другую машину целиком. Готовый файл результата
|
||||||
|
при этом не переносится: продолжение допустимо только на той же платформе, CPU и
|
||||||
|
том же числе логических ядер.
|
||||||
|
|
||||||
|
Перед дорогим запуском нужно проверить набор без декодирования и моделей:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/benchmarks/diarization_calibration.py \
|
||||||
|
--manifest /path/to/manifest.json \
|
||||||
|
--validate-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Полный запуск и его продолжение используют одну команду и один файл результата:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/benchmarks/diarization_calibration.py \
|
||||||
|
--manifest /path/to/manifest.json \
|
||||||
|
--output /path/to/results.json \
|
||||||
|
--work-dir /path/to/work
|
||||||
|
```
|
||||||
|
|
||||||
|
Совместимый файл продолжится с первой незавершенной ячейки. Код возврата 0
|
||||||
|
означает, что завершены все автоматические ячейки и ячейки с известным числом
|
||||||
|
говорящих. Число кластеров и инвариантность текста нужно сверить с условиями
|
||||||
|
конкретного исследования. Полный контракт аргументов и режим подготовки
|
||||||
|
ASR-sidecar описаны в `--help`. Результат включает исходный manifest,
|
||||||
|
приватные пути и интервалы. Поэтому его нельзя добавлять в Git или передавать
|
||||||
|
внешнему ревьюеру.
|
||||||
|
|
||||||
## Оборудование и условия
|
## Оборудование и условия
|
||||||
|
|
||||||
- Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров;
|
- Intel Core i7-6820HQ, 4 ядра / 8 логических процессоров;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**Дата:** 2026-08-29
|
**Дата:** 2026-08-29
|
||||||
|
|
||||||
**Статус:** исследовательская записка. Код, зависимости и рабочая конфигурация
|
**Статус:** исследовательская записка по публичным источникам. Выводы позднее
|
||||||
не менялись.
|
проверены локальным CPU-стендом; рабочая конфигурация не менялась.
|
||||||
|
|
||||||
**Метод:** использованы только первичные публичные источники: документация,
|
**Метод:** использованы только первичные публичные источники: документация,
|
||||||
исходный код и выпуски sherpa-onnx, карточки владельцев моделей и исходная
|
исходный код и выпуски sherpa-onnx, карточки владельцев моделей и исходная
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import itertools
|
import itertools
|
||||||
@@ -24,6 +25,7 @@ from tempfile import NamedTemporaryFile
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 2
|
||||||
|
EXPECTED_SHERPA_ONNX_VERSION = "1.13.5"
|
||||||
SAMPLE_RATE = 16_000
|
SAMPLE_RATE = 16_000
|
||||||
SAMPLE_WIDTH_BYTES = 2
|
SAMPLE_WIDTH_BYTES = 2
|
||||||
CHANNELS = 1
|
CHANNELS = 1
|
||||||
@@ -129,24 +131,73 @@ class PeakRssSampler:
|
|||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser(
|
||||||
parser.add_argument("--manifest", type=Path)
|
description=(
|
||||||
parser.add_argument("--output", type=Path)
|
"Проверяет и запускает воспроизводимый CPU-benchmark из issue #25. "
|
||||||
parser.add_argument("--work-dir", type=Path)
|
"Приватные пути и результаты остаются вне Git."
|
||||||
|
),
|
||||||
|
epilog=(
|
||||||
|
"Сначала выполните --validate-only. Полный запуск атомарно сохраняет "
|
||||||
|
"каждую ячейку и продолжает совместимый output после прерывания."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--manifest",
|
||||||
|
type=Path,
|
||||||
|
help="manifest v2; относительные пути считаются от его каталога",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
help="приватный JSON результата; существующий совместимый файл продолжится",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--work-dir",
|
||||||
|
type=Path,
|
||||||
|
help="каталог декодированных WAV и запросов дочерним процессам",
|
||||||
|
)
|
||||||
# Оставлено для воспроизводимости старых команд; значение входит в experiment_id.
|
# Оставлено для воспроизводимости старых команд; значение входит в experiment_id.
|
||||||
parser.add_argument("--threads", type=int)
|
parser.add_argument(
|
||||||
parser.add_argument("--prepare-asr", metavar="RECORDING_ID")
|
"--threads",
|
||||||
parser.add_argument("--asr-model")
|
type=int,
|
||||||
parser.add_argument("--asr-device")
|
help="число потоков; если задано, должно совпасть с manifest.threads",
|
||||||
parser.add_argument("--asr-compute-type")
|
)
|
||||||
parser.add_argument("--asr-language")
|
parser.add_argument(
|
||||||
|
"--validate-only",
|
||||||
|
action="store_true",
|
||||||
|
help="проверить файлы, корпус и матрицу без декодирования и запуска моделей",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prepare-asr",
|
||||||
|
metavar="RECORDING_ID",
|
||||||
|
help="создать ASR-sidecar одной записи и вывести его SHA-256",
|
||||||
|
)
|
||||||
|
parser.add_argument("--asr-model", help="production ASR-модель для --prepare-asr")
|
||||||
|
parser.add_argument("--asr-device", help="устройство ASR для --prepare-asr")
|
||||||
|
parser.add_argument("--asr-compute-type", help="точность ASR для --prepare-asr")
|
||||||
|
parser.add_argument("--asr-language", help="язык ASR для --prepare-asr")
|
||||||
parser.add_argument("--worker", type=Path, help=argparse.SUPPRESS)
|
parser.add_argument("--worker", type=Path, help=argparse.SUPPRESS)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.worker is None:
|
if args.worker is None:
|
||||||
missing = [
|
validate_only_conflicts = (
|
||||||
name for name in ("manifest", "work_dir") if getattr(args, name) is None
|
"output",
|
||||||
]
|
"work_dir",
|
||||||
if args.prepare_asr is None and args.output is None:
|
"prepare_asr",
|
||||||
|
"asr_model",
|
||||||
|
"asr_device",
|
||||||
|
"asr_compute_type",
|
||||||
|
"asr_language",
|
||||||
|
)
|
||||||
|
if args.validate_only and any(
|
||||||
|
getattr(args, name) is not None for name in validate_only_conflicts
|
||||||
|
):
|
||||||
|
parser.error(
|
||||||
|
"--validate-only нельзя сочетать с параметрами запуска или подготовки ASR"
|
||||||
|
)
|
||||||
|
missing = ["manifest"] if args.manifest is None else []
|
||||||
|
if not args.validate_only and args.work_dir is None:
|
||||||
|
missing.append("work_dir")
|
||||||
|
if not args.validate_only and args.prepare_asr is None and args.output is None:
|
||||||
missing.append("output")
|
missing.append("output")
|
||||||
if missing:
|
if missing:
|
||||||
parser.error("обязательные параметры: " + ", ".join(missing))
|
parser.error("обязательные параметры: " + ", ".join(missing))
|
||||||
@@ -166,6 +217,29 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_manifest_paths(
|
||||||
|
manifest: dict[str, Any], manifest_dir: Path
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Разрешает приватные относительные пути от каталога manifest."""
|
||||||
|
result = copy.deepcopy(manifest)
|
||||||
|
base = manifest_dir.resolve()
|
||||||
|
|
||||||
|
def resolve(value: str) -> str:
|
||||||
|
path = Path(value)
|
||||||
|
return str(path if path.is_absolute() else (base / path).resolve())
|
||||||
|
|
||||||
|
for model in result["segmentation_models"]:
|
||||||
|
model["path"] = resolve(model["path"])
|
||||||
|
for model in result["embedding_models"]:
|
||||||
|
model["path"] = resolve(model["path"])
|
||||||
|
for recording in result["recordings"]:
|
||||||
|
recording["path"] = resolve(recording["path"])
|
||||||
|
if recording["reference"] is not None:
|
||||||
|
recording["reference"]["path"] = resolve(recording["reference"]["path"])
|
||||||
|
recording["asr_words"]["path"] = resolve(recording["asr_words"]["path"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def file_sha256(path: Path) -> str:
|
def file_sha256(path: Path) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with path.open("rb") as source:
|
with path.open("rb") as source:
|
||||||
@@ -461,9 +535,31 @@ def environment_snapshot() -> dict[str, Any]:
|
|||||||
"sherpa_onnx_version": version("sherpa-onnx"),
|
"sherpa_onnx_version": version("sherpa-onnx"),
|
||||||
"onnxruntime_version": version("onnxruntime"),
|
"onnxruntime_version": version("onnxruntime"),
|
||||||
"numpy_version": version("numpy"),
|
"numpy_version": version("numpy"),
|
||||||
|
"psutil_version": version("psutil"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_environment(environment: dict[str, Any]) -> None:
|
||||||
|
"""Отсекает окружение, которое заведомо испортит дорогой прогон."""
|
||||||
|
missing = [
|
||||||
|
key
|
||||||
|
for key in (
|
||||||
|
"sherpa_onnx_version",
|
||||||
|
"onnxruntime_version",
|
||||||
|
"numpy_version",
|
||||||
|
"psutil_version",
|
||||||
|
)
|
||||||
|
if environment.get(key) == "unavailable"
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(f"Не установлены зависимости benchmark: {missing}")
|
||||||
|
if environment.get("sherpa_onnx_version") != EXPECTED_SHERPA_ONNX_VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Stage timing поддержан только для sherpa-onnx "
|
||||||
|
f"{EXPECTED_SHERPA_ONNX_VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def semantic_manifest(
|
def semantic_manifest(
|
||||||
manifest: dict[str, Any], environment: dict[str, Any]
|
manifest: dict[str, Any], environment: dict[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -652,7 +748,7 @@ def select_threshold(
|
|||||||
|
|
||||||
def parse_stage_timings(stderr: str, sherpa_version: str) -> dict[str, float]:
|
def parse_stage_timings(stderr: str, sherpa_version: str) -> dict[str, float]:
|
||||||
"""Строго разбирает недокументированный debug-контракт sherpa 1.13.5."""
|
"""Строго разбирает недокументированный debug-контракт sherpa 1.13.5."""
|
||||||
if sherpa_version != "1.13.5":
|
if sherpa_version != EXPECTED_SHERPA_ONNX_VERSION:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Неизвестный формат stage timing sherpa-onnx {sherpa_version}"
|
f"Неизвестный формат stage timing sherpa-onnx {sherpa_version}"
|
||||||
)
|
)
|
||||||
@@ -1411,12 +1507,40 @@ def _prepare_asr(args: argparse.Namespace, manifest: dict[str, Any]) -> None:
|
|||||||
print(digest)
|
print(digest)
|
||||||
|
|
||||||
|
|
||||||
|
def validation_summary(
|
||||||
|
manifest: dict[str, Any], environment: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Возвращает безопасный план без приватных путей и текста."""
|
||||||
|
experiment_id = make_experiment_id(manifest, environment)
|
||||||
|
return {
|
||||||
|
"status": "valid",
|
||||||
|
"experiment_id": experiment_id,
|
||||||
|
"recordings": [item["id"] for item in manifest["recordings"]],
|
||||||
|
"combinations": [item["id"] for item in manifest["combinations"]],
|
||||||
|
"automatic_cells": len(expand_automatic_cells(manifest, experiment_id)),
|
||||||
|
"known_cells_if_all_selected": len(manifest["recordings"])
|
||||||
|
* len(manifest["combinations"]),
|
||||||
|
"threads": manifest["threads"],
|
||||||
|
"dependencies": {
|
||||||
|
key: environment[key]
|
||||||
|
for key in (
|
||||||
|
"sherpa_onnx_version",
|
||||||
|
"onnxruntime_version",
|
||||||
|
"numpy_version",
|
||||||
|
"psutil_version",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
args = parse_args(argv)
|
args = parse_args(argv)
|
||||||
if args.worker is not None:
|
if args.worker is not None:
|
||||||
_worker_main(args.worker)
|
_worker_main(args.worker)
|
||||||
return
|
return
|
||||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
raw_manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
|
validate_manifest(raw_manifest, verify_files=False)
|
||||||
|
manifest = resolve_manifest_paths(raw_manifest, args.manifest.parent)
|
||||||
if args.threads is not None:
|
if args.threads is not None:
|
||||||
if args.threads <= 0:
|
if args.threads <= 0:
|
||||||
raise ValueError("--threads должен быть положительным")
|
raise ValueError("--threads должен быть положительным")
|
||||||
@@ -1427,8 +1551,18 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
return
|
return
|
||||||
validate_manifest(manifest)
|
validate_manifest(manifest)
|
||||||
validate_original_corpus(manifest)
|
validate_original_corpus(manifest)
|
||||||
args.work_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
environment = environment_snapshot()
|
environment = environment_snapshot()
|
||||||
|
validate_environment(environment)
|
||||||
|
if args.validate_only:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
validation_summary(manifest, environment),
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
args.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
experiment_id = make_experiment_id(manifest, environment)
|
experiment_id = make_experiment_id(manifest, environment)
|
||||||
output = _load_or_create_output(args.output, manifest, environment, experiment_id)
|
output = _load_or_create_output(args.output, manifest, environment, experiment_id)
|
||||||
decoded = {
|
decoded = {
|
||||||
|
|||||||
@@ -140,9 +140,88 @@ def _environment():
|
|||||||
"sherpa_onnx_version": "1.13.5",
|
"sherpa_onnx_version": "1.13.5",
|
||||||
"onnxruntime_version": "1",
|
"onnxruntime_version": "1",
|
||||||
"numpy_version": "2",
|
"numpy_version": "2",
|
||||||
|
"psutil_version": "7",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_only_requires_only_manifest():
|
||||||
|
args = calibration.parse_args(["--manifest", "manifest.json", "--validate-only"])
|
||||||
|
|
||||||
|
assert args.manifest == Path("manifest.json")
|
||||||
|
assert args.output is None
|
||||||
|
assert args.work_dir is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"extra",
|
||||||
|
[
|
||||||
|
["--output", "unused"],
|
||||||
|
["--work-dir", "unused"],
|
||||||
|
["--prepare-asr", "data-test"],
|
||||||
|
["--asr-model", "model"],
|
||||||
|
["--asr-device", "cpu"],
|
||||||
|
["--asr-compute-type", "float32"],
|
||||||
|
["--asr-language", "ru"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_only_rejects_unused_run_options(extra):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
calibration.parse_args(
|
||||||
|
["--manifest", "manifest.json", "--validate-only", *extra]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_manifest_paths_resolve_from_manifest_directory(tmp_path):
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
for model in manifest["segmentation_models"] + manifest["embedding_models"]:
|
||||||
|
model["path"] = Path(model["path"]).name
|
||||||
|
for recording in manifest["recordings"]:
|
||||||
|
recording["path"] = Path(recording["path"]).name
|
||||||
|
recording["asr_words"]["path"] = Path(recording["asr_words"]["path"]).name
|
||||||
|
if recording["reference"] is not None:
|
||||||
|
recording["reference"]["path"] = Path(recording["reference"]["path"]).name
|
||||||
|
|
||||||
|
resolved = calibration.resolve_manifest_paths(manifest, tmp_path)
|
||||||
|
|
||||||
|
assert calibration.validate_manifest(resolved)["schema_version"] == 2
|
||||||
|
assert Path(resolved["recordings"][0]["path"]).is_absolute()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_only_prints_safe_plan(tmp_path, monkeypatch, capsys):
|
||||||
|
manifest_path = tmp_path / "manifest.json"
|
||||||
|
manifest = _manifest(tmp_path)
|
||||||
|
for model in manifest["segmentation_models"] + manifest["embedding_models"]:
|
||||||
|
model["path"] = Path(model["path"]).name
|
||||||
|
for recording in manifest["recordings"]:
|
||||||
|
recording["path"] = Path(recording["path"]).name
|
||||||
|
recording["asr_words"]["path"] = Path(recording["asr_words"]["path"]).name
|
||||||
|
if recording["reference"] is not None:
|
||||||
|
recording["reference"]["path"] = Path(recording["reference"]["path"]).name
|
||||||
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(calibration, "validate_original_corpus", lambda manifest: None)
|
||||||
|
monkeypatch.setattr(calibration, "environment_snapshot", _environment)
|
||||||
|
|
||||||
|
calibration.main(["--manifest", str(manifest_path), "--validate-only"])
|
||||||
|
|
||||||
|
summary = json.loads(capsys.readouterr().out)
|
||||||
|
assert summary["status"] == "valid"
|
||||||
|
assert summary["automatic_cells"] == 15
|
||||||
|
assert summary["known_cells_if_all_selected"] == 12
|
||||||
|
assert str(tmp_path) not in json.dumps(summary)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "value"),
|
||||||
|
[("sherpa_onnx_version", "1.14.0"), ("psutil_version", "unavailable")],
|
||||||
|
)
|
||||||
|
def test_validate_environment_rejects_incompatible_dependencies(key, value):
|
||||||
|
environment = _environment()
|
||||||
|
environment[key] = value
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
calibration.validate_environment(environment)
|
||||||
|
|
||||||
|
|
||||||
def _complete_cell(spec, clusters, purity=None, residual=0.0):
|
def _complete_cell(spec, clusters, purity=None, residual=0.0):
|
||||||
return {
|
return {
|
||||||
**spec,
|
**spec,
|
||||||
|
|||||||
Reference in new issue
Block a user