limoXD's picture
Release v0.2 Mozc-backed Japanese IME reranker
f11438f verified
Raw
History Blame Contribute Delete
12.6 kB
from __future__ import annotations
import math
import random
import re
import time
from collections import Counter, defaultdict
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from .domain import (
Candidate,
CandidateScorer,
CandidateScoringError,
RerankConfig,
RerankRequest,
)
from .reranker import Reranker
JAPANESE_RE = re.compile(r"[ぁ-んァ-ヶ一-龯々〆ヵヶ]")
@dataclass(frozen=True, slots=True)
class CorpusToken:
surface: str
reading: str
upos: str
@dataclass(frozen=True, slots=True)
class BenchmarkExample:
request: RerankRequest
expected: str
@dataclass(frozen=True, slots=True)
class SplitCoverage:
eligible_tokens: int
ambiguous_known_reading: int
oracle_in_pool: int
oracle_miss: int
@dataclass(frozen=True, slots=True)
class PreparedSplit:
examples: tuple[BenchmarkExample, ...]
coverage: SplitCoverage
@dataclass(frozen=True, slots=True)
class ScoredExample:
request: RerankRequest
expected: str
model_scores: tuple[float, ...] | None
latency_ms: float
error_code: str | None = None
@dataclass(frozen=True, slots=True)
class ScoredSplit:
examples: tuple[ScoredExample, ...]
total_available: int
scoring_errors: int
elapsed_seconds: float
@property
def examples_per_second(self) -> float:
if self.elapsed_seconds == 0.0:
return 0.0
return len(self.examples) / self.elapsed_seconds
@dataclass(frozen=True, slots=True)
class SettingMetrics:
total: int
baseline_correct: int
reranked_correct: int
improved: int
regressed: int
both_correct: int
both_wrong: int
changed: int
@property
def baseline_accuracy(self) -> float:
return self.baseline_correct / self.total if self.total else 0.0
@property
def reranked_accuracy(self) -> float:
return self.reranked_correct / self.total if self.total else 0.0
@property
def absolute_gain(self) -> float:
return self.reranked_accuracy - self.baseline_accuracy
@dataclass(frozen=True, slots=True)
class SelectedSetting:
prior_weight: float
min_margin: float
metrics: SettingMetrics
@dataclass(frozen=True, slots=True)
class ComparisonOutcome:
example: ScoredExample
baseline_prediction: str
reranked_prediction: str
baseline_correct: bool
reranked_correct: bool
changed: bool
reason: str
class _PrecomputedScorer:
def __init__(self, scores: Sequence[float]) -> None:
self._scores = scores
def score_candidates(self, request: RerankRequest) -> Sequence[float]:
return self._scores
def parse_conllu(text: str) -> tuple[tuple[CorpusToken, ...], ...]:
sentences: list[tuple[CorpusToken, ...]] = []
current: list[CorpusToken] = []
for line in text.splitlines():
if not line:
if current:
sentences.append(tuple(current))
current = []
continue
if line.startswith("#"):
continue
fields = line.split("\t")
if len(fields) != 10 or not fields[0].isdigit():
continue
reading = ""
for item in fields[9].split("|"):
if item.startswith("UnidicInfo="):
parts = item.removeprefix("UnidicInfo=").split(",")
lemma_reading = parts[0] if parts else ""
reading = parts[4] if len(parts) > 4 and parts[4] else lemma_reading
break
current.append(CorpusToken(surface=fields[1], reading=reading, upos=fields[3]))
if current:
sentences.append(tuple(current))
return tuple(sentences)
def prepare_examples(
train_sentences: tuple[tuple[CorpusToken, ...], ...],
evaluation_sentences: tuple[tuple[CorpusToken, ...], ...],
*,
pool_size: int,
) -> PreparedSplit:
lexicon: dict[tuple[str, str], Counter[str]] = defaultdict(Counter)
for sentence in train_sentences:
for token in sentence:
if token.reading and JAPANESE_RE.search(token.surface):
lexicon[(token.reading, token.upos)][token.surface] += 1
eligible_tokens = 0
ambiguous_known_reading = 0
oracle_in_pool = 0
oracle_miss = 0
examples: list[BenchmarkExample] = []
for sentence in evaluation_sentences:
surfaces = tuple(token.surface for token in sentence)
for target_index, token in enumerate(sentence):
if not token.reading or not JAPANESE_RE.search(token.surface):
continue
eligible_tokens += 1
counts = lexicon.get((token.reading, token.upos))
if not counts or len(counts) < 2:
continue
ambiguous_known_reading += 1
pool = sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:pool_size]
if token.surface not in {surface for surface, _ in pool}:
oracle_miss += 1
continue
oracle_in_pool += 1
examples.append(
BenchmarkExample(
request=RerankRequest(
reading=token.reading,
left_context=surfaces[:target_index],
right_context=surfaces[target_index + 1 :],
candidates=tuple(
Candidate(surface=surface, prior_score=math.log1p(count))
for surface, count in pool
),
),
expected=token.surface,
)
)
return PreparedSplit(
examples=tuple(examples),
coverage=SplitCoverage(
eligible_tokens=eligible_tokens,
ambiguous_known_reading=ambiguous_known_reading,
oracle_in_pool=oracle_in_pool,
oracle_miss=oracle_miss,
),
)
def evaluate_setting(
examples: Sequence[ScoredExample],
*,
prior_weight: float,
min_margin: float,
) -> SettingMetrics:
outcomes = compare_examples(
examples,
prior_weight=prior_weight,
min_margin=min_margin,
)
return SettingMetrics(
total=len(examples),
baseline_correct=sum(outcome.baseline_correct for outcome in outcomes),
reranked_correct=sum(outcome.reranked_correct for outcome in outcomes),
improved=sum(
not outcome.baseline_correct and outcome.reranked_correct for outcome in outcomes
),
regressed=sum(
outcome.baseline_correct and not outcome.reranked_correct for outcome in outcomes
),
both_correct=sum(
outcome.baseline_correct and outcome.reranked_correct for outcome in outcomes
),
both_wrong=sum(
not outcome.baseline_correct and not outcome.reranked_correct
for outcome in outcomes
),
changed=sum(outcome.changed for outcome in outcomes),
)
def compare_examples(
examples: Sequence[ScoredExample],
*,
prior_weight: float,
min_margin: float,
) -> tuple[ComparisonOutcome, ...]:
outcomes: list[ComparisonOutcome] = []
for example in examples:
baseline_prediction = example.request.candidates[0].surface
if example.model_scores is None:
reranked_prediction = baseline_prediction
changed = False
reason = "scoring_error"
else:
result = Reranker(
_PrecomputedScorer(example.model_scores),
RerankConfig(prior_weight=prior_weight, min_margin=min_margin),
).rerank(example.request)
reranked_prediction = result.ranked[0].surface
changed = result.changed
reason = result.reason
outcomes.append(
ComparisonOutcome(
example=example,
baseline_prediction=baseline_prediction,
reranked_prediction=reranked_prediction,
baseline_correct=baseline_prediction == example.expected,
reranked_correct=reranked_prediction == example.expected,
changed=changed,
reason=reason,
)
)
return tuple(outcomes)
def select_setting(
examples: Sequence[ScoredExample],
*,
prior_weights: Sequence[float],
min_margins: Sequence[float],
) -> SelectedSetting:
selected: SelectedSetting | None = None
selected_key: tuple[int, int, int, float, float] | None = None
for prior_weight in prior_weights:
for min_margin in min_margins:
metrics = evaluate_setting(
examples,
prior_weight=prior_weight,
min_margin=min_margin,
)
key = (
metrics.reranked_correct,
-metrics.regressed,
-metrics.changed,
min_margin,
prior_weight,
)
if selected_key is None or key > selected_key:
selected_key = key
selected = SelectedSetting(
prior_weight=prior_weight,
min_margin=min_margin,
metrics=metrics,
)
if selected is None:
raise ValueError("at least one prior weight and margin are required")
return selected
def mcnemar_exact_p(*, improved: int, regressed: int) -> float:
discordant = improved + regressed
if discordant == 0:
return 1.0
tail = min(improved, regressed)
log_probabilities = [
math.lgamma(discordant + 1)
- math.lgamma(value + 1)
- math.lgamma(discordant - value + 1)
- discordant * math.log(2.0)
for value in range(tail + 1)
]
largest = max(log_probabilities)
one_sided = math.exp(largest) * sum(
math.exp(value - largest) for value in log_probabilities
)
return min(1.0, 2.0 * one_sided)
def paired_bootstrap_gain_interval(
differences: Sequence[int],
*,
samples: int,
seed: int,
) -> tuple[float, float]:
if not differences:
return (0.0, 0.0)
if samples < 1:
raise ValueError("samples must be positive")
rng = random.Random(seed)
count = len(differences)
gains = sorted(
sum(differences[rng.randrange(count)] for _ in range(count)) / count
for _ in range(samples)
)
lower_index = int(0.025 * (samples - 1))
upper_index = math.ceil(0.975 * (samples - 1))
return gains[lower_index], gains[upper_index]
def score_prepared_split(
prepared: PreparedSplit,
scorer: CandidateScorer,
*,
limit: int | None = None,
seed: int = 20260810,
progress: Callable[[int, int], None] | None = None,
) -> ScoredSplit:
available = prepared.examples
if limit is not None and limit < len(available):
rng = random.Random(seed)
indexes = sorted(rng.sample(range(len(available)), limit))
selected = tuple(available[index] for index in indexes)
else:
selected = available
scored_examples: list[ScoredExample] = []
scoring_errors = 0
started = time.perf_counter()
for completed, example in enumerate(selected, start=1):
row_started = time.perf_counter()
error_code: str | None = None
try:
values = tuple(float(value) for value in scorer.score_candidates(example.request))
if len(values) != len(example.request.candidates) or not all(
math.isfinite(value) for value in values
):
raise ValueError("scorer returned invalid scores")
model_scores: tuple[float, ...] | None = values
except CandidateScoringError as error:
model_scores = None
scoring_errors += 1
error_code = error.code
except Exception:
model_scores = None
scoring_errors += 1
error_code = "scorer_exception"
scored_examples.append(
ScoredExample(
request=example.request,
expected=example.expected,
model_scores=model_scores,
latency_ms=(time.perf_counter() - row_started) * 1000.0,
error_code=error_code,
)
)
if progress is not None:
progress(completed, len(selected))
return ScoredSplit(
examples=tuple(scored_examples),
total_available=len(available),
scoring_errors=scoring_errors,
elapsed_seconds=time.perf_counter() - started,
)