deberta-v2-tiny-japanese-ime / src /deberta_ime /benchmark_v2_runner.py
limoXD's picture
Release v0.2 Mozc-backed Japanese IME reranker
f11438f verified
Raw
History Blame Contribute Delete
19.3 kB
from __future__ import annotations
import json
import platform
import sys
from collections import Counter
from collections.abc import Callable
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
from .benchmark import (
ComparisonOutcome,
ScoredSplit,
SettingMetrics,
compare_examples,
evaluate_setting,
mcnemar_exact_p,
paired_bootstrap_gain_interval,
parse_conllu,
score_prepared_split,
select_setting,
)
from .benchmark_v2 import prepare_mozc_examples
from .deberta import MODEL_ID, MODEL_REVISION
from .domain import CandidateScorer
from .mozc import MozcDictionaryIndex
from .ud_gsd import UD_LICENSE, UD_REPOSITORY, CorpusArtifact
@dataclass(frozen=True, slots=True)
class FrozenProfile:
name: str
context_mode: str
prior_weight: float
min_margin: float
selected_on: str
@dataclass(frozen=True, slots=True)
class V2BenchmarkConfig:
pool_size: int = 8
limit: int | None = None
seed: int = 20260810
prior_weights: tuple[float, ...] = (
0.0,
0.25,
0.5,
0.75,
1.0,
1.5,
2.0,
3.0,
4.0,
6.0,
8.0,
)
min_margins: tuple[float, ...] = (0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0)
bootstrap_samples: int = 5000
example_limit: int = 20
@dataclass(frozen=True, slots=True)
class V2BenchmarkRun:
report: dict[str, Any]
markdown: str
ProgressCallback = Callable[[int, int], None]
def write_v2_outputs(
run: V2BenchmarkRun,
*,
output_dir: Path,
stem: str,
) -> tuple[Path, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
json_path = output_dir / f"{stem}.json"
markdown_path = output_dir / f"{stem}.md"
json_path.write_text(
json.dumps(run.report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
markdown_path.write_text(run.markdown, encoding="utf-8")
return json_path, markdown_path
def run_dev_selection(
scorer: CandidateScorer,
*,
index: MozcDictionaryIndex,
artifact: CorpusArtifact,
context_mode: str,
config: V2BenchmarkConfig | None = None,
model_load_seconds: float | None = None,
progress: ProgressCallback | None = None,
) -> V2BenchmarkRun:
active_config = config or V2BenchmarkConfig()
prepared = prepare_mozc_examples(
parse_conllu(artifact.text),
index,
pool_size=active_config.pool_size,
context_mode=context_mode,
)
scored = score_prepared_split(
prepared,
scorer,
limit=active_config.limit,
seed=active_config.seed,
progress=progress,
)
selected = select_setting(
scored.examples,
prior_weights=active_config.prior_weights,
min_margins=active_config.min_margins,
)
model_only = evaluate_setting(
scored.examples,
prior_weight=0.0,
min_margin=0.0,
)
ambiguous = prepared.coverage.ambiguous_known_reading
report: dict[str, Any] = {
"schema_version": 2,
"generated_at": datetime.now(UTC).isoformat(),
"status": "LOCAL_BENCHMARK",
"stage": "DEV_SELECTION",
"model": {
"id": MODEL_ID,
"revision": MODEL_REVISION,
"task": "masked-language-model finite-candidate reranking",
"candidate_generation": False,
},
"candidate_source": {
"kind": "Mozc OSS dictionary SQLite index",
"manifest": asdict(index.manifest),
"pool_size": active_config.pool_size,
"prior_score": "-(cost - best_cost) / 1000",
},
"dataset": {
"repository": UD_REPOSITORY,
"revision": artifact.revision,
"license": UD_LICENSE,
"file": {
"url": artifact.url,
"sha256": artifact.sha256,
"size_bytes": artifact.size_bytes,
"cache_path": str(artifact.path),
},
},
"selection": {
"source": "GSD dev only",
"dataset_revision": artifact.revision,
"context_mode": context_mode,
"prior_weights": list(active_config.prior_weights),
"min_margins": list(active_config.min_margins),
"selected_prior_weight": selected.prior_weight,
"selected_min_margin": selected.min_margin,
},
"sampling": {
"limit": active_config.limit,
"seed": active_config.seed,
"available": scored.total_available,
"evaluated": len(scored.examples),
},
"coverage": _coverage_payload(prepared.coverage),
"conditional_metrics": {
"baseline": _baseline_payload(selected.metrics),
"model_only": _reranked_payload(model_only),
"selected": _reranked_payload(selected.metrics),
},
"end_to_end_ambiguous": {
"denominator": ambiguous,
"baseline_accuracy": (
selected.metrics.baseline_correct / ambiguous if ambiguous else 0.0
),
"model_only_accuracy": (
model_only.reranked_correct / ambiguous if ambiguous else 0.0
),
"selected_accuracy": (
selected.metrics.reranked_correct / ambiguous if ambiguous else 0.0
),
},
"performance": _performance_payload(scored, model_load_seconds=model_load_seconds),
"environment": _environment_payload(),
"claim_boundaries": [
"This report selects hyperparameters and is not external evaluation evidence.",
"Conditional accuracy requires the expected surface in Mozc top-k.",
"PUD and AJIMEE are not read or scored by this function.",
],
}
return V2BenchmarkRun(report=report, markdown=_render_dev_markdown(report))
def run_external_evaluation(
scorer: CandidateScorer,
*,
index: MozcDictionaryIndex,
artifact: CorpusArtifact,
dataset_repository: str,
dataset_license: str,
profile: FrozenProfile,
config: V2BenchmarkConfig | None = None,
model_load_seconds: float | None = None,
progress: ProgressCallback | None = None,
) -> V2BenchmarkRun:
active_config = config or V2BenchmarkConfig()
prepared = prepare_mozc_examples(
parse_conllu(artifact.text),
index,
pool_size=active_config.pool_size,
context_mode=profile.context_mode,
)
scored = score_prepared_split(
prepared,
scorer,
limit=active_config.limit,
seed=active_config.seed,
progress=progress,
)
tuned = evaluate_setting(
scored.examples,
prior_weight=profile.prior_weight,
min_margin=profile.min_margin,
)
model_only = evaluate_setting(
scored.examples,
prior_weight=0.0,
min_margin=0.0,
)
outcomes = compare_examples(
scored.examples,
prior_weight=profile.prior_weight,
min_margin=profile.min_margin,
)
differences = [
int(outcome.reranked_correct) - int(outcome.baseline_correct) for outcome in outcomes
]
interval = paired_bootstrap_gain_interval(
differences,
samples=active_config.bootstrap_samples,
seed=active_config.seed,
)
ambiguous = prepared.coverage.ambiguous_known_reading
report: dict[str, Any] = {
"schema_version": 2,
"generated_at": datetime.now(UTC).isoformat(),
"status": "LOCAL_BENCHMARK",
"stage": "EXTERNAL_EVALUATION",
"model": {
"id": MODEL_ID,
"revision": MODEL_REVISION,
"task": "masked-language-model finite-candidate reranking",
"candidate_generation": False,
},
"candidate_source": {
"kind": "Mozc OSS dictionary SQLite index",
"manifest": asdict(index.manifest),
"pool_size": active_config.pool_size,
"prior_score": "-(cost - best_cost) / 1000",
},
"dataset": {
"repository": dataset_repository,
"revision": artifact.revision,
"license": dataset_license,
"file": {
"url": artifact.url,
"sha256": artifact.sha256,
"size_bytes": artifact.size_bytes,
"cache_path": str(artifact.path),
},
},
"profile": {**asdict(profile), "source": "frozen"},
"sampling": {
"limit": active_config.limit,
"seed": active_config.seed,
"available": scored.total_available,
"evaluated": len(scored.examples),
},
"coverage": _coverage_payload(prepared.coverage),
"conditional_metrics": {
"baseline": _baseline_payload(tuned),
"model_only": _reranked_payload(model_only),
"tuned": _reranked_payload(tuned),
},
"end_to_end_ambiguous": {
"denominator": ambiguous,
"baseline_accuracy": tuned.baseline_correct / ambiguous if ambiguous else 0.0,
"model_only_accuracy": (
model_only.reranked_correct / ambiguous if ambiguous else 0.0
),
"tuned_accuracy": tuned.reranked_correct / ambiguous if ambiguous else 0.0,
},
"paired_statistics": {
"absolute_gain": tuned.absolute_gain,
"bootstrap_95pct_gain_interval": list(interval),
"bootstrap_samples": active_config.bootstrap_samples,
"bootstrap_seed": active_config.seed,
"mcnemar_exact_p": mcnemar_exact_p(
improved=tuned.improved,
regressed=tuned.regressed,
),
"improved": tuned.improved,
"regressed": tuned.regressed,
},
"performance": _performance_payload(scored, model_load_seconds=model_load_seconds),
"environment": _environment_payload(),
"examples": _examples_payload(outcomes, limit=active_config.example_limit),
"claim_boundaries": [
"Conditional accuracy requires the expected surface in Mozc top-k.",
"Raw romaji conversion and sentence-level candidate generation are not evaluated.",
"Gold UD word boundaries are supplied as context.",
"This is local CPU evidence, not TSF, device, public, or Human GO evidence.",
],
}
return V2BenchmarkRun(report=report, markdown=_render_external_markdown(report))
def _coverage_payload(coverage: Any) -> dict[str, Any]:
ambiguous = coverage.ambiguous_known_reading
return {
"eligible": coverage.eligible_tokens,
"ambiguous": ambiguous,
"oracle_in_pool": coverage.oracle_in_pool,
"oracle_miss": coverage.oracle_miss,
"candidate_recall_at_k": coverage.oracle_in_pool / ambiguous if ambiguous else 0.0,
}
def _baseline_payload(metrics: SettingMetrics) -> dict[str, Any]:
return {
"correct": metrics.baseline_correct,
"total": metrics.total,
"accuracy": metrics.baseline_accuracy,
}
def _reranked_payload(metrics: SettingMetrics) -> dict[str, Any]:
return {
"correct": metrics.reranked_correct,
"total": metrics.total,
"accuracy": metrics.reranked_accuracy,
"absolute_gain_vs_baseline": metrics.absolute_gain,
"changed": metrics.changed,
"improved": metrics.improved,
"regressed": metrics.regressed,
"both_correct": metrics.both_correct,
"both_wrong": metrics.both_wrong,
}
def _performance_payload(
scored: ScoredSplit,
*,
model_load_seconds: float | None,
) -> dict[str, Any]:
latencies = sorted(example.latency_ms for example in scored.examples)
failure_counts = Counter(
example.error_code for example in scored.examples if example.error_code is not None
)
return {
"device": "cpu",
"model_load_seconds": model_load_seconds,
"elapsed_seconds": scored.elapsed_seconds,
"examples_per_second": scored.examples_per_second,
"latency_ms_p50": _percentile(latencies, 0.5),
"latency_ms_p95": _percentile(latencies, 0.95),
"scoring_errors": scored.scoring_errors,
"scoring_failure_counts": dict(sorted(failure_counts.items())),
"scoring_error_rate": (
scored.scoring_errors / len(scored.examples) if scored.examples else 0.0
),
}
def _environment_payload() -> dict[str, str]:
return {
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": _package_version("torch"),
"transformers": _package_version("transformers"),
}
def _package_version(package: str) -> str:
try:
return version(package)
except PackageNotFoundError:
return "unknown"
def _percentile(values: list[float], fraction: float) -> float:
if not values:
return 0.0
position = fraction * (len(values) - 1)
lower = int(position)
upper = min(lower + 1, len(values) - 1)
weight = position - lower
return values[lower] * (1.0 - weight) + values[upper] * weight
def _examples_payload(
outcomes: tuple[ComparisonOutcome, ...],
*,
limit: int,
) -> dict[str, Any]:
def payload(outcome: ComparisonOutcome) -> dict[str, Any]:
return {
"reading": outcome.example.request.reading,
"expected": outcome.example.expected,
"baseline": outcome.baseline_prediction,
"reranked": outcome.reranked_prediction,
"reason": outcome.reason,
}
return {
"improvements": [
payload(outcome)
for outcome in outcomes
if not outcome.baseline_correct and outcome.reranked_correct
][:limit],
"regressions": [
payload(outcome)
for outcome in outcomes
if outcome.baseline_correct and not outcome.reranked_correct
][:limit],
}
def _render_external_markdown(report: dict[str, Any]) -> str:
metrics = report["conditional_metrics"]
coverage = report["coverage"]
profile = report["profile"]
end_to_end = report["end_to_end_ambiguous"]
statistics_payload = report["paired_statistics"]
performance = report["performance"]
interval = statistics_payload["bootstrap_95pct_gain_interval"]
failures = json.dumps(
performance["scoring_failure_counts"], ensure_ascii=False, sort_keys=True
)
lines = [
"# DeBERTa Japanese IME v0.2 external evaluation",
"",
f"生成日時: {report['generated_at']}",
"",
(
f"Profile: `{profile['name']}` ({profile['context_mode']}), "
f"prior weight={profile['prior_weight']}, min margin={profile['min_margin']}。"
),
f"選択元: `{profile['selected_on']}`。外部データで再調整していない。",
"",
"## 候補coverage",
"",
(
f"eligible {coverage['eligible']}、複数候補 {coverage['ambiguous']}、"
f"Mozc top-{report['candidate_source']['pool_size']} candidate recall "
f"{coverage['candidate_recall_at_k']:.2%} "
f"({coverage['oracle_in_pool']}/{coverage['ambiguous']})、"
f"candidate miss {coverage['oracle_miss']}。"
),
"",
"## Oracle-in-pool 条件付きTop-1",
"",
"| 系 | Accuracy | 正解数 | ベースライン差 |",
"|---|---:|---:|---:|",
(
f"| Mozc cost | {metrics['baseline']['accuracy']:.2%} | "
f"{metrics['baseline']['correct']}/{metrics['baseline']['total']} | - |"
),
(
f"| DeBERTa単体 | {metrics['model_only']['accuracy']:.2%} | "
f"{metrics['model_only']['correct']}/{metrics['model_only']['total']} | "
f"{metrics['model_only']['absolute_gain_vs_baseline']:+.2%} |"
),
(
f"| 凍結済み混合 | {metrics['tuned']['accuracy']:.2%} | "
f"{metrics['tuned']['correct']}/{metrics['tuned']['total']} | "
f"{metrics['tuned']['absolute_gain_vs_baseline']:+.2%} |"
),
"",
(
f"改善 {statistics_payload['improved']}件、悪化 "
f"{statistics_payload['regressed']}件。paired bootstrap 95% CI "
f"{interval[0]:+.2%}..{interval[1]:+.2%}、"
f"McNemar exact p={statistics_payload['mcnemar_exact_p']:.3g}。"
),
"",
"## Candidate miss込みTop-1",
"",
(
f"複数候補全体 {end_to_end['denominator']}件で、Mozc "
f"{end_to_end['baseline_accuracy']:.2%}、凍結済み混合 "
f"{end_to_end['tuned_accuracy']:.2%}。candidate miss は誤りとして数えた。"
),
"",
"## CPUとfail-closed",
"",
(
f"{performance['examples_per_second']:.2f} examples/s、p50 "
f"{performance['latency_ms_p50']:.2f} ms、p95 "
f"{performance['latency_ms_p95']:.2f} ms。scoring errors "
f"{performance['scoring_errors']} ({performance['scoring_error_rate']:.2%})、"
f"内訳 `{failures}`。すべてMozc順位維持として分母に含めた。"
),
"",
"## 主張できないこと",
"",
]
lines.extend(f"- {boundary}" for boundary in report["claim_boundaries"])
lines.append("")
return "\n".join(lines)
def _render_dev_markdown(report: dict[str, Any]) -> str:
metrics = report["conditional_metrics"]
coverage = report["coverage"]
selection = report["selection"]
return "\n".join(
[
"# DeBERTa Japanese IME v0.2 dev selection",
"",
f"生成日時: {report['generated_at']}",
"",
f"Context: `{selection['context_mode']}`",
(
f"Selected: prior_weight={selection['selected_prior_weight']}, "
f"min_margin={selection['selected_min_margin']}"
),
"",
(
f"Mozc top-{report['candidate_source']['pool_size']} candidate recall: "
f"{coverage['candidate_recall_at_k']:.2%} "
f"({coverage['oracle_in_pool']}/{coverage['ambiguous']})"
),
"",
"| 系 | Oracle-in-pool Top-1 | ベースライン差 |",
"|---|---:|---:|",
f"| Mozc cost | {metrics['baseline']['accuracy']:.2%} | - |",
(
f"| DeBERTa単体 | {metrics['model_only']['accuracy']:.2%} | "
f"{metrics['model_only']['absolute_gain_vs_baseline']:+.2%} |"
),
(
f"| dev選択混合 | {metrics['selected']['accuracy']:.2%} | "
f"{metrics['selected']['absolute_gain_vs_baseline']:+.2%} |"
),
"",
"DEV_SELECTION のため、外部評価の主張には使わない。",
"",
]
)