Voltline's picture
Release frozen VimeML tiny-ja-v1 FP32 and Core ML INT8 (GPL-2.0)
2c87024 verified
Raw History Blame Contribute Delete
20.4 kB
"""Cache local LM scores, calibrate on independent development data, evaluate a fixed policy."""
import argparse
import json
import math
import re
import time
import unicodedata
from pathlib import Path
from vimeml.benchmarks.ajimee import DEVELOPMENT_FORMAT, ROOT, convert_items, json_bytes, sha
from vimeml.benchmarks.evaluate_ajimee import load_export, metrics, read_json, rerank, summarize
CACHE_FORMAT = "ime_hybrid_score_cache_v1"
POLICY_FORMAT = "ime_hybrid_policy_v1"
FORMULA = "azookey_score + lambda * contextual_log_probability_sum"
GRID = [0., .05, .1, .2, .3, .5, .75, 1., 1.5, 2., 3.]
def output_directory(path):
if path.exists() and any(path.iterdir()):
raise ValueError("Output is not empty; use a new --output to preserve previous results.")
path.mkdir(parents=True, exist_ok=True)
def canonical(text):
return "".join(unicodedata.normalize("NFKC", text).split())
def overlap_keys(rows):
return ({(canonical(row["query"]), canonical(row["left_context"])) for row in rows},
{canonical(row["left_context"] + answer) for row in rows for answer in row["answers"]})
def check_independence(development, excluded):
dev_queries, dev_texts = overlap_keys(development)
queries, texts = overlap_keys(excluded)
if dev_queries & queries or dev_texts & texts:
raise ValueError("Development examples overlap the excluded AJIMEE set (normalized query/context or context+answer).")
def prepare_development(input_path, output, excluded, labels_reviewed=False):
raw = input_path.read_bytes()
converted, mapping, stats = convert_items(json.loads(raw.decode("utf-8-sig")))
keys, _ = overlap_keys(mapping)
if len(keys) != len(mapping):
raise ValueError("Duplicate development query/context pair.")
if not excluded.exists():
raise ValueError("Excluded AJIMEE source is missing; specify --exclude.")
exclude_raw = excluded.read_bytes()
_, exclude_mapping, _ = convert_items(json.loads(exclude_raw.decode("utf-8-sig")))
check_independence(mapping, exclude_mapping)
output_directory(output)
files = {"evaluation_items.json": raw, "ajimee-input.json": json_bytes(converted), "case-map.json": json_bytes(mapping)}
for name, content in files.items():
(output / name).write_bytes(content)
manifest = {"format": DEVELOPMENT_FORMAT, "status": "complete", "role": "development",
"source_path": str(input_path.resolve()), "source_sha256": sha(raw), "stats": stats,
"labels_reviewed": labels_reviewed,
"label_status": "reviewed_by_user_assertion" if labels_reviewed else "provisional_synthetic_labels",
"excluded_source_sha256": sha(exclude_raw),
"files_sha256": {name: sha(content) for name, content in files.items()},
"policy": "Synthetic development only; all text/references preserved. No candidate/model-score filtering. Reading and label quality require review."}
(output / "manifest.json").write_bytes(json_bytes(manifest))
return manifest
def fingerprint(metadata, provenance):
dictionaries = []
for line in provenance["dictionary-versions.txt"].splitlines():
match = re.match(r"^ ([0-9a-f]{40}) (\S+)", line)
if not match:
raise ValueError("Invalid/unpinned dictionary version record.")
dictionaries.append({"commit": match[1], "path": match[2]})
converter = provenance["converter-version.txt"].strip()
if not re.fullmatch(r"[0-9a-f]{40}", converter) or not dictionaries:
raise ValueError("Missing converter/dictionary commit.")
return {"checkpoint_sha256": metadata["checkpoint_sha256"], "tokenizer_sha256": metadata["tokenizer_sha256"],
"model_config": metadata["model"], "precision": metadata["precision"],
"converter_commit": converter, "dictionaries": sorted(dictionaries, key=lambda row: row["path"]),
"n_best": provenance["n_best"]}
def save_cache(benchmark, output, checkpoint, tokenizer, device, threads):
if output.exists() and any(output.iterdir()):
raise ValueError("Cache output is not empty; choose a new --output.")
manifest, provenance, rows = load_export(benchmark)
import torch
from vimeml.training.infer import JapaneseLM
torch.set_num_threads(threads)
start = time.perf_counter()
lm = JapaneseLM(checkpoint, tokenizer, device)
rerank(lm, rows)
output_directory(output)
content = "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows).encode("utf-8")
(output / "scores.jsonl").write_bytes(content)
cache = {"format": CACHE_FORMAT, "status": "complete", "model": lm.metadata,
"benchmark_manifest": manifest, "export_provenance": provenance,
"files_sha256": {"scores.jsonl": sha(content)}, "fingerprint": fingerprint(lm.metadata, provenance),
"elapsed_seconds": time.perf_counter() - start}
(output / "manifest.json").write_bytes(json_bytes(cache))
return cache
def load_cache(directory):
# Reuse the existing frozen AJIMEE evaluation without another LM forward pass.
path = directory / "manifest.json"
if path.exists():
cache = read_json(path)
if cache.get("format") != CACHE_FORMAT:
raise ValueError("Unknown score cache format.")
else:
cache = read_json(directory / "metrics.json")
if cache.get("format") != "ajimee_ime_metrics_v1":
raise ValueError("Unknown evaluation cache format.")
if cache.get("status") != "complete" or not cache.get("model"):
raise ValueError("Expected complete model scores.")
raw = (directory / "scores.jsonl").read_bytes()
if sha(raw) != cache["files_sha256"]["scores.jsonl"]:
raise ValueError("Cached scores changed.")
rows = [json.loads(line) for line in raw.decode("utf-8").splitlines() if line.strip()]
if not rows or len(rows) != cache["benchmark_manifest"]["stats"]["cases"] or len({r["id"] for r in rows}) != len(rows):
raise ValueError("Invalid score cache sample count/IDs.")
for row in rows:
texts = [c["text"] for c in row["candidates"]]
if texts != row["orders"]["azookey"] or len(set(texts)) != len(texts):
raise ValueError("Cached original candidate order changed.")
if any(not math.isfinite(c["score"]) for c in row["candidates"]):
raise ValueError("Nonfinite AzooKey score.")
scores = row.get("lm_scores", {}).get("contextual")
if "lm_context_sum" in row["fallbacks"]:
if row["orders"]["lm_context_sum"] != texts:
raise ValueError("Invalid whole-case fallback.")
elif not scores or [c["text"] for c in scores["candidates"]] != texts:
raise ValueError("Cached contextual scores do not align with original candidates.")
else:
for candidate in scores["candidates"]:
probabilities = candidate["token_log_probabilities"]
if not probabilities or len(probabilities) != candidate["scored_tokens"] or len(candidate["token_ids"]) != len(probabilities):
raise ValueError("Invalid cached token trace.")
if not all(math.isfinite(v) for v in probabilities) or not math.isclose(sum(probabilities), candidate["log_probability_sum"], abs_tol=1e-6):
raise ValueError("Cached LM total differs from token trace.")
cache["fingerprint"] = fingerprint(cache["model"], cache["export_provenance"])
cache["cache_directory"] = str(directory.resolve())
return cache, rows
def valid_weight(value):
if not math.isfinite(value) or value < 0:
raise ValueError("lambda must be finite and nonnegative.")
return value
def hybrid_order(candidates, scores, weight):
"""No labels, normalization, length bonus or candidate filtering on this path."""
valid_weight(weight)
if weight == 0:
return [c["text"] for c in candidates], [c["score"] for c in candidates]
if [c["text"] for c in candidates] != [c["text"] for c in scores]:
raise ValueError("Hybrid score alignment mismatch.")
values = [c["score"] + weight * s["log_probability_sum"] for c, s in zip(candidates, scores)]
if not all(math.isfinite(v) for v in values):
raise ValueError("Nonfinite combined score.")
indices = sorted(range(len(candidates)), key=lambda i: values[i], reverse=True)
return [candidates[i]["text"] for i in indices], values
def add_hybrid(rows, weight, details=True):
valid_weight(weight)
for row in rows:
if "lm_context_sum" in row["fallbacks"] and weight > 0:
row["orders"]["hybrid"] = list(row["orders"]["azookey"])
row["fallbacks"]["hybrid"] = row["fallbacks"]["lm_context_sum"]
values = None
else:
scored = row.get("lm_scores", {}).get("contextual", {}).get("candidates", [])
row["orders"]["hybrid"], values = hybrid_order(row["candidates"], scored, weight)
row["fallbacks"].pop("hybrid", None)
if details:
row["hybrid_scores_original_order"] = values
def script_profile(text):
return {"kanji": sum('\u3400' <= c <= '\u9fff' for c in text),
"hiragana": sum('\u3040' <= c <= '\u309f' for c in text),
"katakana": sum('\u30a0' <= c <= '\u30ff' for c in text),
"latin": sum(c.isascii() and c.isalpha() for c in text), "digits": sum(c.isdecimal() for c in text)}
def diagnostics(row):
scored = row.get("lm_scores", {}).get("contextual", {}).get("candidates", [])
prefixes, fallback = [], []
for i, candidate in enumerate(scored):
byte_count = sum(piece.startswith("<0x") for piece in candidate["pieces"])
if byte_count:
fallback.append({"text": candidate["text"], "byte_tokens": byte_count})
for other in scored[i + 1:]:
a, b = candidate, other
if len(a["token_ids"]) > len(b["token_ids"]):
a, b = b, a
if len(a["token_ids"]) < len(b["token_ids"]) and b["token_ids"][:len(a["token_ids"])] == a["token_ids"]:
prefixes.append({"shorter": a["text"], "longer": b["text"]})
original, selected = row["orders"]["azookey"], row["orders"]["hybrid"]
before, after = original[0] if original else "", selected[0] if selected else ""
return {"strict_token_prefix_pairs": prefixes, "byte_fallback_candidates": fallback,
"boundary_retokenized_candidates": sum(c["boundary_retokenized"] for c in scored),
"top1_changed": before != after, "before_script_profile": script_profile(before),
"after_script_profile": script_profile(after),
"script_profile_changed": script_profile(before) != script_profile(after)}
def calibrate(cache, rows, grid):
if cache["benchmark_manifest"].get("role") != "development" or cache["benchmark_manifest"]["format"] != DEVELOPMENT_FORMAT:
raise ValueError("Automatic lambda selection requires an independent development cache; AJIMEE cannot be tuned.")
if not cache["benchmark_manifest"].get("labels_reviewed"):
raise ValueError("Development readings/references need review before calibration; prepare a reviewed version with --labels-reviewed.")
if not grid or len(set(grid)) != len(grid):
raise ValueError("Expected a nonempty grid of distinct lambda values.")
records = []
for weight in grid:
valid_weight(weight)
add_hybrid(rows, weight, details=False)
records.append({"lambda": weight, "metrics": metrics(rows, "hybrid")})
chosen = min(records, key=lambda record: (-record["metrics"]["top1_correct"],
record["metrics"]["regressed_vs_azookey"], record["lambda"]))
return {"format": POLICY_FORMAT, "status": "development_selected", "formula": FORMULA,
"lambda": chosen["lambda"], "fingerprint": cache["fingerprint"],
"selection_rule": "Highest development Top-1; ties prefer fewer regressions; remaining ties prefer smaller lambda.",
"development_source_sha256": cache["benchmark_manifest"]["source_sha256"],
"development_scores_sha256": cache["files_sha256"]["scores.jsonl"],
"excluded_source_sha256": cache["benchmark_manifest"]["excluded_source_sha256"],
"development_overlap_keys": {"queries": sorted([list(key) for key in overlap_keys(rows)[0]]),
"texts": sorted(overlap_keys(rows)[1])},
"grid": records}
def validate_policy(policy, cache, rows):
if policy.get("format") != POLICY_FORMAT or policy.get("status") != "development_selected" or policy.get("formula") != FORMULA:
raise ValueError("Invalid selected policy.")
valid_weight(policy["lambda"])
if policy["fingerprint"] != cache["fingerprint"]:
raise ValueError("Policy model/tokenizer/converter/dictionary/precision/n_best differs from this cache.")
if policy["development_source_sha256"] == cache["benchmark_manifest"]["source_sha256"]:
raise ValueError("Selected-policy evaluation must use different data from calibration.")
queries, texts = overlap_keys(rows)
dev = policy["development_overlap_keys"]
if queries & {tuple(key) for key in dev["queries"]} or texts & set(dev["texts"]):
raise ValueError("Evaluation examples overlap calibration data.")
return policy["lambda"]
def evaluate(cache, rows, weight, output, policy=None):
valid_weight(weight)
if output.exists() and any(output.iterdir()):
raise ValueError("Output is not empty; choose a new --output.")
add_hybrid(rows, weight)
changed = []
for row in rows:
row["hybrid_diagnostics"] = diagnostics(row)
if row["hybrid_diagnostics"]["top1_changed"]:
before, after = row["orders"]["azookey"][0], row["orders"]["hybrid"][0]
changed.append({"id": row["id"], "context": row["left_context"], "query": row["query"],
"answers": row["answers"], "before": before, "after": after,
"before_correct": before in row["answers"], "after_correct": after in row["answers"],
"diagnostics": row["hybrid_diagnostics"]})
report = {"format": "ime_hybrid_evaluation_v1", "status": "complete", "formula": FORMULA, "lambda": weight,
"coefficient_source": "independent_development_policy" if policy else "user_specified_not_calibrated",
"policy": policy, "score_cache": cache, "metrics": summarize(rows),
"diagnostics": {"strict_prefix_cases": sum(bool(r["hybrid_diagnostics"]["strict_token_prefix_pairs"]) for r in rows),
"byte_fallback_cases": sum(bool(r["hybrid_diagnostics"]["byte_fallback_candidates"]) for r in rows),
"top1_changed_cases": len(changed),
"script_profile_changed_cases": sum(r["hybrid_diagnostics"]["script_profile_changed"] for r in rows)},
"policy_notes": "Same real candidate pool; stable ties; raw scores, no EOS/length normalization/extra filters. Whole-case LM fallback. Diagnostics never alter ranking. Strict reference matching; training overlap unknown."}
output_directory(output)
(output / "scores.jsonl").write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows), encoding="utf-8")
(output / "changed-cases.json").write_bytes(json_bytes(changed))
report["files_sha256"] = {name: sha((output / name).read_bytes()) for name in ("scores.jsonl", "changed-cases.json")}
(output / "metrics.json").write_bytes(json_bytes(report))
lines = ["# AzooKey + LM组合评分", "", f"λ = {weight:g};系数来源:{report['coefficient_source']}。", "",
"同一冻结候选池;并列保持原顺序;超长/无法完整计分时整条回退。表记诊断不是自动语义判分。", "",
"| 策略 | 样本数 | Top-1 | Top-5 | MinCER | 纠正 / 改坏 | 回退 |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: |"]
for method in ("azookey", "lm_context_sum", "lm_no_context_sum", "hybrid"):
m = report["metrics"]["all"][method]
lines.append(f"| {method} | {m['cases']} | {m['top1_correct']} ({m['top1_accuracy']:.1%}) | {m['top5_correct']} ({m['top5_accuracy']:.1%}) | "
f"{m['mean_min_cer']:.5f} | {m['corrected_vs_azookey']} / {m['regressed_vs_azookey']} | {m['fallback_cases']} |")
lines += ["", "完整分组、版本与hash见metrics.json,逐候选组合分数与诊断见scores.jsonl,所有首选变化见changed-cases.json。", ""]
(output / "results.md").write_text("\n".join(lines), encoding="utf-8")
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
prepare = sub.add_parser("prepare-dev", help="Prepare reviewed DeepSeek JSON for the same Mac export command.")
prepare.add_argument("--input", type=Path, required=True)
prepare.add_argument("--output", type=Path, default=ROOT / "artifacts/benchmarks/ime-dev-v1")
prepare.add_argument("--exclude", type=Path, default=ROOT / "artifacts/benchmarks/ajimee-jwtd-v2-v1/evaluation_items.json")
prepare.add_argument("--labels-reviewed", action="store_true", help="Assert readings and acceptable references have been independently reviewed.")
score = sub.add_parser("score", help="Compute local LM once for a real candidate export.")
score.add_argument("--benchmark", type=Path, required=True)
score.add_argument("--output", type=Path, required=True)
score.add_argument("--checkpoint", type=Path, default=ROOT / "artifacts/models/tiny-ja-v1/best.pt")
score.add_argument("--tokenizer", type=Path, default=ROOT / "artifacts/tokenizers/ja-unigram-16k-v1")
score.add_argument("--device", choices=("cpu", "cuda"), default="cpu")
score.add_argument("--threads", type=int, default=4)
tune = sub.add_parser("tune", help="Select lambda only on reviewed independent development data.")
tune.add_argument("--scores", type=Path, required=True)
tune.add_argument("--grid", type=float, nargs="+", default=GRID)
tune.add_argument("--output", type=Path, required=True)
run = sub.add_parser("evaluate", help="Evaluate one fixed lambda or a saved development policy.")
run.add_argument("--scores", type=Path, default=ROOT / "outputs/ime-eval/tiny-ja-v1-ajimee")
option = run.add_mutually_exclusive_group(required=True)
option.add_argument("--lambda", type=float, dest="weight")
option.add_argument("--policy", type=Path)
run.add_argument("--output", type=Path, required=True)
args = parser.parse_args(argv)
try:
if args.command == "prepare-dev":
result = prepare_development(args.input, args.output, args.exclude, args.labels_reviewed)
print(json.dumps(result["stats"], indent=2))
print(f"Mac input: {args.output.resolve() / 'ajimee-input.json'}")
elif args.command == "score":
if args.threads < 1:
raise ValueError("threads must be positive.")
save_cache(args.benchmark, args.output, args.checkpoint, args.tokenizer, args.device, args.threads)
print(f"Score cache: {args.output.resolve()}")
else:
cache, rows = load_cache(args.scores)
if args.command == "tune":
result = calibrate(cache, rows, args.grid)
output_directory(args.output)
(args.output / "policy.json").write_bytes(json_bytes(result))
print(f"Development-selected lambda: {result['lambda']:g}")
print(f"Policy: {args.output.resolve() / 'policy.json'}")
else:
policy = read_json(args.policy) if args.policy else None
weight = validate_policy(policy, cache, rows) if policy else args.weight
report = evaluate(cache, rows, weight, args.output, policy)
print(json.dumps(report["metrics"]["all"]["hybrid"], indent=2))
print(f"Report: {args.output.resolve() / 'results.md'}")
except (ValueError, OSError, KeyError, TypeError) as error:
parser.exit(2, f"Error: {error}\n")
if __name__ == "__main__":
main()