deberta-v2-tiny-japanese-ime / tests /test_benchmark.py
limoXD's picture
Release v0.2 Mozc-backed Japanese IME reranker
f11438f verified
Raw
History Blame Contribute Delete
8.78 kB
from __future__ import annotations
import math
import pytest
from deberta_ime import Candidate, CandidateScoringError, RerankRequest
from deberta_ime.benchmark import (
ScoredExample,
evaluate_setting,
mcnemar_exact_p,
paired_bootstrap_gain_interval,
parse_conllu,
prepare_examples,
score_prepared_split,
select_setting,
)
from deberta_ime.benchmark_runner import BenchmarkConfig, run_benchmark
from deberta_ime.benchmark_v2 import prepare_mozc_examples
from deberta_ime.mozc import MozcDictionaryIndex, build_mozc_index
from deberta_ime.ud_gsd import CorpusArtifact
CONLLU = """\
# sent_id = sample-1
# text = 川の橋を渡る。
1\t川\t川\tNOUN\t名詞\t_\t3\tnmod\t_\tSpaceAfter=No|UnidicInfo=カワ,川,川
2\tの\tの\tADP\t助詞\t_\t1\tcase\t_\tSpaceAfter=No|UnidicInfo=ノ,の,の
3\t橋\t橋\tNOUN\t名詞\t_\t4\tobj\t_\tSpaceAfter=No|UnidicInfo=ハシ,橋,橋
4\tを\tを\tADP\t助詞\t_\t3\tcase\t_\tSpaceAfter=No|UnidicInfo=ヲ,を,を
5\t渡る\t渡る\tVERB\t動詞\t_\t0\troot\t_\tSpaceAfter=No|UnidicInfo=ワタル,渡る,渡る
6\t。\t。\tPUNCT\t補助記号\t_\t5\tpunct\t_\tUnidicInfo=。,。,。
"""
TRAIN_CONLLU = """\
# sent_id = train-1
1\t箸\t箸\tNOUN\t名詞\t_\t0\troot\t_\tUnidicInfo=ハシ,箸,箸
# sent_id = train-2
1\t箸\t箸\tNOUN\t名詞\t_\t0\troot\t_\tUnidicInfo=ハシ,箸,箸
# sent_id = train-3
1\t橋\t橋\tNOUN\t名詞\t_\t0\troot\t_\tUnidicInfo=ハシ,橋,橋
"""
INFLECTED_CONLLU = """\
# sent_id = inflected-1
1\tし\t為る\tAUX\t動詞\t_\t0\troot\t_\tUnidicInfo=スル,為る,し,する,シ,,,スル,スル,する
"""
def test_parse_conllu_extracts_surface_reading_and_pos() -> None:
sentences = parse_conllu(CONLLU)
assert len(sentences) == 1
assert [(token.surface, token.reading, token.upos) for token in sentences[0]] == [
("川", "カワ", "NOUN"),
("の", "ノ", "ADP"),
("橋", "ハシ", "NOUN"),
("を", "ヲ", "ADP"),
("渡る", "ワタル", "VERB"),
("。", "。", "PUNCT"),
]
def test_parse_conllu_uses_surface_pronunciation_instead_of_lexeme_reading() -> None:
sentences = parse_conllu(INFLECTED_CONLLU)
assert sentences[0][0].surface == "し"
assert sentences[0][0].reading == "シ"
def test_prepare_examples_builds_frequency_ranked_oracle_candidate_pool() -> None:
prepared = prepare_examples(
parse_conllu(TRAIN_CONLLU),
parse_conllu(CONLLU),
pool_size=8,
)
assert prepared.coverage.ambiguous_known_reading == 1
assert prepared.coverage.oracle_in_pool == 1
assert prepared.coverage.oracle_miss == 0
assert len(prepared.examples) == 1
example = prepared.examples[0]
assert example.expected == "橋"
assert [candidate.surface for candidate in example.request.candidates] == ["箸", "橋"]
assert [candidate.prior_score for candidate in example.request.candidates] == [
math.log1p(2),
math.log1p(1),
]
assert example.request.left_context == ("川", "の")
assert example.request.right_context == ("を", "渡る", "。")
def test_prepare_mozc_examples_uses_surface_reading_cost_and_context_mode(tmp_path) -> None:
dictionary_dir = tmp_path / "dictionary_oss"
dictionary_dir.mkdir()
(dictionary_dir / "dictionary00.txt").write_text(
"はし\t1\t1\t3500\t箸\nはし\t1\t1\t3800\t橋\n",
encoding="utf-8",
)
index_path = tmp_path / "mozc.sqlite3"
build_mozc_index(dictionary_dir, index_path, source_revision="fixture")
with MozcDictionaryIndex(index_path) as index:
prepared = prepare_mozc_examples(
parse_conllu(CONLLU),
index,
pool_size=8,
context_mode="left_only",
)
assert prepared.coverage.ambiguous_known_reading == 1
assert prepared.coverage.oracle_in_pool == 1
assert prepared.coverage.oracle_miss == 0
example = prepared.examples[0]
assert example.expected == "橋"
assert [candidate.surface for candidate in example.request.candidates] == ["箸", "橋"]
assert [candidate.prior_score for candidate in example.request.candidates] == pytest.approx(
[0.0, -0.3]
)
assert example.request.left_context == ("川", "の")
assert example.request.right_context == ()
def test_evaluate_setting_separates_improvements_and_regressions() -> None:
def scored(
expected: str, candidates: tuple[str, ...], scores: tuple[float, ...]
) -> ScoredExample:
return ScoredExample(
request=RerankRequest(
reading="テスト",
candidates=tuple(Candidate(surface) for surface in candidates),
),
expected=expected,
model_scores=scores,
latency_ms=1.0,
)
examples = (
scored("正", ("誤", "正"), (0.0, 2.0)),
scored("正", ("正", "誤"), (0.0, 2.0)),
scored("正", ("正", "誤"), (2.0, 0.0)),
scored("正", ("誤一", "正", "誤二"), (0.0, 1.0, 2.0)),
)
metrics = evaluate_setting(examples, prior_weight=0.0, min_margin=0.0)
assert metrics.total == 4
assert metrics.baseline_correct == 2
assert metrics.reranked_correct == 2
assert metrics.improved == 1
assert metrics.regressed == 1
assert metrics.both_correct == 1
assert metrics.both_wrong == 1
assert metrics.changed == 3
def test_select_setting_uses_only_supplied_development_rows() -> None:
request = RerankRequest(
reading="ハシ",
candidates=(Candidate("箸", prior_score=2.0), Candidate("橋", prior_score=0.0)),
)
rows = (
ScoredExample(
request=request,
expected="橋",
model_scores=(0.0, 2.0),
latency_ms=1.0,
),
)
selected = select_setting(
rows,
prior_weights=(0.0, 2.0),
min_margins=(0.0, 3.0),
)
assert selected.prior_weight == 0.0
assert selected.min_margin == 0.0
assert selected.metrics.reranked_correct == 1
def test_paired_statistics_have_known_exact_cases() -> None:
assert mcnemar_exact_p(improved=10, regressed=0) == pytest.approx(0.001953125)
assert mcnemar_exact_p(improved=5, regressed=5) == 1.0
assert paired_bootstrap_gain_interval(
[1] * 20,
samples=200,
seed=20260810,
) == (1.0, 1.0)
def test_score_prepared_split_keeps_rows_and_records_model_failures() -> None:
prepared = prepare_examples(
parse_conllu(TRAIN_CONLLU),
parse_conllu(CONLLU),
pool_size=8,
)
class FailingScorer:
def score_candidates(self, request: RerankRequest) -> list[float]:
raise RuntimeError("unavailable")
scored = score_prepared_split(prepared, FailingScorer())
assert len(scored.examples) == 1
assert scored.examples[0].model_scores is None
assert scored.scoring_errors == 1
assert scored.total_available == 1
def test_score_prepared_split_records_stable_failure_codes() -> None:
prepared = prepare_examples(
parse_conllu(TRAIN_CONLLU),
parse_conllu(CONLLU),
pool_size=8,
)
class UnknownTokenScorer:
def score_candidates(self, request: RerankRequest) -> list[float]:
raise CandidateScoringError("unknown_token", "candidate contains unknown token")
scored = score_prepared_split(prepared, UnknownTokenScorer())
assert scored.examples[0].error_code == "unknown_token"
def test_benchmark_report_tunes_on_dev_and_reports_test_separately(tmp_path) -> None:
texts = {"train": TRAIN_CONLLU, "dev": CONLLU, "test": CONLLU}
def artifact_loader(split: str, cache_dir) -> CorpusArtifact:
text = texts[split]
return CorpusArtifact(
split=split,
path=tmp_path / f"{split}.conllu",
url=f"https://example.invalid/{split}",
revision="fixture",
sha256=split * 8,
size_bytes=len(text.encode()),
text=text,
)
class ContextScorer:
def score_candidates(self, request: RerankRequest) -> list[float]:
return [0.0, 2.0]
run = run_benchmark(
ContextScorer(),
data_dir=tmp_path,
config=BenchmarkConfig(
prior_weights=(0.0,),
min_margins=(0.0,),
bootstrap_samples=50,
prototype_test_exclusion_count=0,
),
artifact_loader=artifact_loader,
)
test_metrics = run.report["metrics"]["sealed_test_remainder"]
assert run.report["selection"]["source"] == "dev only"
assert test_metrics["baseline"]["correct"] == 0
assert test_metrics["tuned"]["correct"] == 1
assert run.report["paired_test_statistics"]["improved"] == 1