Download source/scripts/benchmarks/evaluate_candidate_pools.py from Voltline/vimeml-tiny-zh-v1: direct link, hf CLI and curl.
- Browser
- Download file 7.43 kB
-
https://huggingface.co/Voltline/vimeml-tiny-zh-v1/resolve/main/source/scripts/benchmarks/evaluate_candidate_pools.py
- Command line
-
hf download hf://Voltline/vimeml-tiny-zh-v1/source/scripts/benchmarks/evaluate_candidate_pools.py
-
curl -L -o evaluate_candidate_pools.py https://huggingface.co/Voltline/vimeml-tiny-zh-v1/resolve/main/source/scripts/benchmarks/evaluate_candidate_pools.py
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() | |