vimeml-tiny-zh-v1 / source /scripts /benchmarks /evaluate_candidate_pools.py
Voltline's picture
Release vimeml-tiny-zh-v1 (step 844730): FP32 bundle, INT8 Core ML packages, source snapshot
81cffe7 verified
Raw History Blame Contribute Delete
7.43 kB
"""Development-only candidate-pool checks for the candidate export v2.
Compares the imported n-best 20/50 and empty-context pools of Standard v1
development with the original export, then rescores the n-best 50 pool with
V2.1 and V4.2 (FP32, same semantics as the frozen comparisons) and reports
exact / notation-lenient Top-1 against the recorded n-best 20 scores.
Blind and homophone tracks are refused.
"""
import argparse
import copy
import json
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'src'))
import torch
from vimeml.training.data import file_sha, write_json
from vimeml.benchmarks.standard_ime import load_standard_export
from vimeml.benchmarks.evaluate_ajimee import rerank
from vimeml.benchmarks.comparison import rows as read_rows
from vimeml.benchmarks.notation import error_class
from vimeml.benchmarks import statistics
IMPORTED = ROOT / 'artifacts/benchmarks/ime-candidates-v2-imported'
ORIGINAL = ROOT / 'artifacts/benchmarks/ime-standard-ja-v1-candidates/development'
ALLOWED = ('standard-v1-development-n20', 'standard-v1-development-n50', 'standard-v1-development-nocontext-n20')
MODELS = {
'v21': (ROOT / 'artifacts/models/tiny-ja-v2.1-e16k-d320-l6-extend5/best.pt',
ROOT / 'artifacts/tokenizers/ja-unigram-16k-v2',
ROOT / 'outputs/evaluation-v4-20261015/v21/standard-development/scores.jsonl'),
'v42-e20': (ROOT / 'outputs/training-v42-20261020/remote/checkpoints/best-lm.pt',
ROOT / 'artifacts/tokenizers/v4-a/ja-unigram-8192',
ROOT / 'outputs/evaluation-v42-20261020/standard-development/scores.jsonl'),
}
OUTCOMES = {'exact': ('correct',), 'notation_lenient': ('correct', 'notation')}
def load(name):
if name not in ALLOWED:
raise ValueError('Only exposed development pools are allowed here')
return load_standard_export(IMPORTED / name)[2]
def texts(row):
return [c['text'] for c in row['candidates']]
def covered(row):
return bool(set(texts(row)) & set(row['answers']))
def pool_checks(n20, n50, empty):
original = load_standard_export(ORIGINAL)[2]
by_id = {r['id']: r for r in original}
assert [r['id'] for r in n20] == [r['id'] for r in n50] == [r['id'] for r in empty]
context = [i for i, r in enumerate(n20) if r['left_context']]
gained = [i for i, (a, b) in enumerate(zip(n20, n50)) if not covered(a) and covered(b)]
return {
'determinism_n20': {
'cases': len(n20),
'identical_inputs': sum((r['query'], r['left_context']) == (by_id[r['id']]['query'], by_id[r['id']]['left_context']) for r in n20),
'identical_candidate_lists': sum(texts(r) == texts(by_id[r['id']]) for r in n20)},
'n50': {
'covered_n20': sum(map(covered, n20)), 'covered_n50': sum(map(covered, n50)),
'gained': len(gained), 'lost': sum(covered(a) and not covered(b) for a, b in zip(n20, n50)),
'n20_subset_of_n50': sum(set(texts(a)) <= set(texts(b)) for a, b in zip(n20, n50)),
'same_azookey_top1': sum(texts(a)[0] == texts(b)[0] for a, b in zip(n20, n50)),
'gained_by_source': dict(Counter(n20[i]['source'] for i in gained)),
'mean_pool_size': sum(len(texts(r)) for r in n50) / len(n50)},
'empty_context_n20': {
'with_context': len(context),
'identical_candidate_lists': sum(texts(n20[i]) == texts(empty[i]) for i in context)},
}
def rescore(name, n50):
checkpoint, tokenizer, _ = MODELS[name]
if name == 'v21':
from vimeml.training.infer import JapaneseLM as Model
else:
from vimeml.training.infer_v4 import JapaneseLMV4 as Model
lm = Model(checkpoint, tokenizer, 'cuda')
rows = copy.deepcopy(n50)
with torch.inference_mode():
rerank(lm, rows)
del lm
torch.cuda.empty_cache()
return rows
def consistency(recorded, scored):
"""Contextual scores on candidates shared with the recorded n20 run.
Sums start after the prefix all candidates share, so a new n50 candidate that
shortens it shifts every score in the case by one constant; only the spread of
the per-candidate differences within a case can change a ranking.
"""
spread, prefix_changed, order_kept = 0.0, 0, 0
for a, b in zip(recorded, scored, strict=True):
assert a['id'] == b['id']
ca, cb = a['lm_scores']['contextual'], b['lm_scores']['contextual']
old = {c['text']: c['log_probability_sum'] for c in ca['candidates']}
new = {c['text']: c['log_probability_sum'] for c in cb['candidates']}
diffs = [new[t] - old[t] for t in old if t in new]
spread = max(spread, max(diffs) - min(diffs))
prefix_changed += ca['common_prefix_tokens_including_bos'] != cb['common_prefix_tokens_including_bos']
order_kept += [t for t in a['orders']['lm_context_sum'] if t in new] == \
[t for t in b['orders']['lm_context_sum'] if t in old]
return {'max_within_case_spread': spread, 'common_prefix_changed': prefix_changed,
'shared_order_identical': order_kept, 'cases': len(recorded)}
def compare(recorded, scored):
labels = statistics.clusters(recorded, template=None)
before = [error_class(r, r['orders']['lm_context_sum'][0])[0] for r in recorded]
after = [error_class(r, r['orders']['lm_context_sum'][0])[0] for r in scored]
result = {'n20': dict(Counter(before)), 'n50': dict(Counter(after))}
for metric, ok in OUTCOMES.items():
result[metric] = statistics.paired([c in ok for c in before], [c in ok for c in after], labels)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--output', type=Path, required=True, help='Separate empty output directory')
args = parser.parse_args()
out = args.output.resolve()
if out.exists() and any(out.iterdir()):
parser.error('Use an empty output directory')
if not torch.cuda.is_available():
parser.error('FP32 CUDA scoring is required')
torch.backends.cuda.matmul.allow_tf32 = False
torch.backends.cudnn.allow_tf32 = False
torch.set_float32_matmul_precision('highest')
n20, n50, empty = (load(n) for n in ALLOWED)
summary = {'blind_read': False, 'homophone_read': False, 'script_sha256': file_sha(Path(__file__)),
'pools': pool_checks(n20, n50, empty), 'models': {}}
print(json.dumps(summary['pools'], ensure_ascii=False), flush=True)
out.mkdir(parents=True, exist_ok=True)
for name, (checkpoint, _, recorded_path) in MODELS.items():
recorded = read_rows(recorded_path)
assert [r['id'] for r in recorded] == [r['id'] for r in n50]
scored = rescore(name, n50)
(out / f'{name}-n50-scores.jsonl').write_text(
''.join(json.dumps(r, ensure_ascii=False, allow_nan=False) + '\n' for r in scored), encoding='utf-8')
entry = {'checkpoint_sha256': file_sha(checkpoint), 'shared_scores': consistency(recorded, scored),
**compare(recorded, scored)}
summary['models'][name] = entry
print(name, json.dumps({k: v for k, v in entry.items() if k != 'checkpoint_sha256'}, ensure_ascii=False), flush=True)
write_json(out / 'summary.json', summary)
if __name__ == '__main__':
main()