"""교정된 Math Ink 0.6 federation checkpoint의 source·writer·label 병목을 감사한다.""" from __future__ import annotations import argparse import json from pathlib import Path import sys import torch from torch.utils.data import DataLoader PROJECT_ROOT = Path(__file__).parents[1] SOURCE_ROOT = PROJECT_ROOT / "src" if str(SOURCE_ROOT) not in sys.path: sys.path.insert(0, str(SOURCE_ROOT)) if str(PROJECT_ROOT / "scripts") not in sys.path: sys.path.insert(0, str(PROJECT_ROOT / "scripts")) from audit_math_ink_06_online_errors import summarize_online_predictions06 from math_grid_drawer.research.case_context06 import ( SIZE_DEPENDENT_CASE_BASES06, apply_relative_case_context06, ) from math_grid_drawer.research.ink06_federation import ( FederatedPairedInk06Dataset, load_product_federation06, resolve_training_device06, ) from math_grid_drawer.research.math_ink_06 import MathInk06Engine def _bbox_context06(record: dict) -> dict: """필요 변수: 원본 source stroke·canvas. 작동 원리: 128 정규화 전에 있던 상대 높이를 context resolver용 bbox로 보존한다.""" points = [] for stroke in record.get("strokes") or []: values = stroke.get("points", []) if isinstance(stroke, dict) else stroke for point in values: if isinstance(point, dict): points.append((float(point["x"]), float(point["y"]))) else: points.append((float(point[0]), float(point[1]))) if not points: return dict(record) canvas = record.get("canvas") or {} height = max(float(canvas.get("height", 1.0)), 1e-6) top = min(point[1] for point in points) / height bottom = max(point[1] for point in points) / height output = dict(record) output["baseline_context"] = { "bbox_top": top, "bbox_bottom": bottom, "bbox_height": max(bottom - top, 1e-6), } return output def _case_size_proxy06( *, logits: torch.Tensor, labels: tuple[str, ...], records: list[dict], ) -> dict: """필요 변수: writer별 logits·원본 bbox. 작동 원리: 동일 writer를 행 anchor의 대리값으로 삼아 대소문자 상대크기 이득과 회귀를 함께 센다.""" enriched = [_bbox_context06(record) for record in records] groups: dict[tuple[str, str], list[int]] = {} for index, record in enumerate(enriched): key = (str(record.get("source")), str(record.get("writer_key") or "missing")) groups.setdefault(key, []).append(index) predictions = logits.argmax(dim=1) source_rows: dict[str, dict[str, int]] = {} for (source, _writer), indices in groups.items(): group_logits = logits[indices] group_records = [enriched[index] for index in indices] resolved, decisions = apply_relative_case_context06(group_logits, labels, group_records) raw = group_logits.argmax(dim=1) revised = resolved.argmax(dim=1) for local_index, global_index in enumerate(indices): truth = str(records[global_index]["label"]) if len(truth) != 1 or not truth.isascii() or truth.lower() not in SIZE_DEPENDENT_CASE_BASES06: continue row = source_rows.setdefault(source, { "samples": 0, "raw_correct": 0, "resolved_correct": 0, "changed": 0, "beneficial": 0, "harmful": 0, }) raw_label = labels[int(raw[local_index])] resolved_label = labels[int(revised[local_index])] row["samples"] += 1 row["raw_correct"] += int(raw_label == truth) row["resolved_correct"] += int(resolved_label == truth) row["changed"] += int(raw_label != resolved_label) row["beneficial"] += int(raw_label != truth and resolved_label == truth) row["harmful"] += int(raw_label == truth and resolved_label != truth) by_source = { source: { **row, "raw_top1": row["raw_correct"] / row["samples"] if row["samples"] else 0.0, "resolved_top1": row["resolved_correct"] / row["samples"] if row["samples"] else 0.0, "delta_percentage_points": ( (row["resolved_correct"] - row["raw_correct"]) * 100.0 / row["samples"] if row["samples"] else 0.0 ), } for source, row in sorted(source_rows.items()) } return { "scope": "same-writer isolated-glyph relative-size proxy; not continuous-formula product evidence", "writers": len(groups), "by_source": by_source, "product_validation": False, } def _evaluate_online06( engine: MathInk06Engine, records: list[dict], *, batch_size: int, ) -> tuple[torch.Tensor, torch.Tensor]: """필요 변수: 교정 test record·engine. 작동 원리: 동일 분모의 정답과 exact log-probability를 CPU에 모은다.""" exact_to_index = {label: index for index, label in enumerate(engine.labels)} family_to_index = {label: index for index, label in enumerate(engine.family_labels)} loader = DataLoader( FederatedPairedInk06Dataset(records, exact_to_index, family_to_index), batch_size=batch_size, shuffle=False, num_workers=0, ) targets: list[torch.Tensor] = [] probabilities: list[torch.Tensor] = [] engine.model.eval() with torch.inference_mode(): for online, _raster, _coordinates, _states, target, _family, _source in loader: logits, family_logits = engine.model.forward_online(online.to(engine.device)) fused = engine._fuse_online_exact06(logits, family_logits) targets.append(target.cpu()) probabilities.append(fused.log_softmax(dim=1).cpu()) return torch.cat(targets), torch.cat(probabilities) def main() -> None: """필요 변수: provenance checkpoint·승인 federation. 작동 원리: 실제 명시 test의 source/writer/label 오류를 JSON으로 고정한다.""" parser = argparse.ArgumentParser(description="Audit clean federation exact bottlenecks") parser.add_argument("--checkpoint", type=Path, required=True) parser.add_argument("--registry", type=Path, default=PROJECT_ROOT / "research/dataset_registry.json") parser.add_argument("--source-registry", type=Path, default=PROJECT_ROOT / "research/math_ink_06_source_registry.json") parser.add_argument("--commercial", type=Path, default=PROJECT_ROOT / "research/data/external_trajectory_v1/commercial_ccby4.jsonl.gz") parser.add_argument("--hwrt", type=Path, default=PROJECT_ROOT / "research/data/open_pretrain/hwrt_expanded_v2/hwrt_expanded.jsonl.gz") parser.add_argument("--approval", type=Path, default=PROJECT_ROOT / "research/approvals/HWRT-ODBL-USE-APPROVAL-v1.json") parser.add_argument("--output", type=Path, required=True) parser.add_argument("--batch-size", type=int, default=32) parser.add_argument("--maximum-per-source", type=int, default=0) parser.add_argument("--device", default="auto") args = parser.parse_args() device = resolve_training_device06(args.device) engine = MathInk06Engine(args.checkpoint, device=device) sources = load_product_federation06( registry_path=args.registry, commercial_path=args.commercial, hwrt_path=args.hwrt, approval_path=args.approval, allowed_labels=engine.labels, source_registry_path=args.source_registry, ) training_metadata = [ record for source in sources for record in source.records if record.get("eligible_for_training") ] test_records: list[dict] = [] for source in sources: rows = [record for record in source.records if record.get("split") == "test"] if args.maximum_per_source > 0: rows = rows[:args.maximum_per_source] test_records.extend(rows) targets, log_probabilities = _evaluate_online06( engine, test_records, batch_size=args.batch_size, ) metadata = [{ **record, "writer_key": str(record.get("writer_key") or record.get("writer_id") or "missing"), } for record in test_records] report = summarize_online_predictions06( labels=engine.labels, metadata=metadata, training_metadata=training_metadata, targets=targets, log_probabilities=log_probabilities, exact_family_index=engine.exact_family_index.cpu(), ) source_bottlenecks = {} for source_id in sorted({str(record["source"]) for record in metadata}): indices = [ index for index, record in enumerate(metadata) if str(record["source"]) == source_id ] index_tensor = torch.tensor(indices, dtype=torch.long) source_report = summarize_online_predictions06( labels=engine.labels, metadata=[metadata[index] for index in indices], training_metadata=[ record for record in training_metadata if str(record["source"]) == source_id ], targets=targets.index_select(0, index_tensor), log_probabilities=log_probabilities.index_select(0, index_tensor), exact_family_index=engine.exact_family_index.cpu(), ) source_bottlenecks[source_id] = { key: source_report[key] for key in ( "samples", "top1", "top5", "shape_family_top1", "same_family_error_rate", "writer_accuracy_p10", "writer_accuracy_minimum", "top_confusions", "highest_error_labels", "lowest_supported_labels", ) } report["source_bottlenecks"] = source_bottlenecks report["case_size_proxy"] = _case_size_proxy06( logits=log_probabilities, labels=engine.labels, records=metadata, ) report.update({ "checkpoint": str(args.checkpoint), "device": device, "training_source_ids": list(torch.load( args.checkpoint, map_location="cpu", weights_only=False, ).get("training_source_ids", [])), "product_validation": False, }) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(json.dumps({ key: report[key] for key in ( "samples", "top1", "top5", "shape_family_top1", "same_family_error_rate", "source_metrics", "writer_accuracy_p10", "writer_accuracy_minimum", "top_confusions", ) }, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()