aiflow-math-ink-06-intermediate / scripts /audit_math_ink_06_online_error_consensus.py
cwLeeDev's picture
Add online consensus audit and family-fusion runtime fix
0dcf39e verified
Raw
History Blame Contribute Delete
12.2 kB
"""์„ธ seed์˜ online ์˜ค๋ฅ˜ ํ•ฉ์˜๋„๋ฅผ ๊ณ„์‚ฐํ•ด ๋ฐ์ดํ„ฐยท๋ชจ๋ธยทseed ๋ถ„์‚ฐ ๋ณ‘๋ชฉ์„ ๋ถ„๋ฆฌํ•œ๋‹ค."""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
from datetime import datetime, timezone
import json
from pathlib import Path
import sys
from typing import Sequence
import torch
PROJECT_ROOT = Path(__file__).parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
for path in (PROJECT_ROOT, SOURCE_ROOT):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from math_grid_drawer.research.trajectory_sequence import shape_family, visual_label_family
from scripts.train_math_ink_06_p_boundary_auxiliary import _load_encoder06
from scripts.train_math_ink_06_skeleton_adapter import _resolve_device06
def _parse_args() -> argparse.Namespace:
"""ํ•„์š” ๋ณ€์ˆ˜: feature cacheยทseed๋ณ„ checkpoint. ์ž‘๋™ ์›๋ฆฌ: ๋™์ผ ๋ถ„๋ชจ์˜ ํ•ฉ์˜ ์˜ค๋ฅ˜ ๊ฐ์‚ฌ CLI๋ฅผ ๋งŒ๋“ ๋‹ค."""
parser = argparse.ArgumentParser(description="Audit Math Ink 0.6 online error consensus")
parser.add_argument("--feature-cache", type=Path, required=True)
parser.add_argument("--base-checkpoint", type=Path, action="append", required=True)
parser.add_argument("--adapter-checkpoint", type=Path, action="append", required=True)
parser.add_argument("--source-counts", type=Path)
parser.add_argument("--source-split", choices=("validation", "paired_test"), default="validation")
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if len(args.base_checkpoint) != len(args.adapter_checkpoint):
raise ValueError("base์™€ adapter checkpoint ๊ฐœ์ˆ˜๋Š” ๊ฐ™์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
if len(args.base_checkpoint) < 2:
raise ValueError("seed ํ•ฉ์˜ ๊ฐ์‚ฌ์—๋Š” checkpoint ๋‘ ๊ฐœ ์ด์ƒ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
return args
def _source_vector06(
samples: int, source_counts_path: Path | None, split: str,
) -> list[str]:
"""ํ•„์š” ๋ณ€์ˆ˜: ์ด ํ‘œ๋ณธ ์ˆ˜ยทsource๋ณ„ ๊ฐœ์ˆ˜ ๋ณด๊ณ ์„œ. ์ž‘๋™ ์›๋ฆฌ: cache ์ƒ์„ฑ ์ˆœ์„œ์™€ ๊ฐ™์€ source ๊ตฌ๊ฐ„์„ ๋ณต์›ํ•œ๋‹ค."""
if source_counts_path is None:
return ["unknown"] * samples
payload = json.loads(source_counts_path.read_text(encoding="utf-8"))
counts = payload.get("paired_source_counts", payload)
values: list[str] = []
for source in ("hwrt", "uci-uji-pen-v1", "uci-uji-pen-v2"):
if source not in counts:
continue
count = int(counts[source][split])
values.extend([source] * count)
if len(values) != samples:
raise ValueError(f"source count ํ•ฉ๊ณ„๊ฐ€ cache์™€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค: {len(values)} != {samples}")
return values
def summarize_online_consensus06(
logits_by_seed: Sequence[torch.Tensor],
targets: torch.Tensor,
writers: torch.Tensor,
labels: Sequence[str],
sources: Sequence[str],
) -> dict:
"""ํ•„์š” ๋ณ€์ˆ˜: seed๋ณ„ logitยท์ •๋‹ตยทwriter/source. ์ž‘๋™ ์›๋ฆฌ: ๊ณตํ†ต ์˜ค๋ฅ˜์™€ ensemble ์ƒํ•œ์„ ํ•œ ๋ถ„๋ชจ์—์„œ ๊ณ„์‚ฐํ•œ๋‹ค."""
if not logits_by_seed:
raise ValueError("seed logit์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.")
samples = len(targets)
if any(len(logits) != samples for logits in logits_by_seed):
raise ValueError("seed logit ํ‘œ๋ณธ ์ˆ˜๊ฐ€ ์„œ๋กœ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.")
if len(writers) != samples or len(sources) != samples:
raise ValueError("writer/source ํ‘œ๋ณธ ์ˆ˜๊ฐ€ target๊ณผ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.")
predictions = torch.stack([logits.argmax(dim=-1) for logits in logits_by_seed])
correctness = predictions.eq(targets.unsqueeze(0))
ensemble_logits = torch.stack(
[logits.log_softmax(dim=-1) for logits in logits_by_seed],
).logsumexp(dim=0)
ensemble_prediction = ensemble_logits.argmax(dim=-1)
ensemble_top5 = ensemble_logits.topk(min(5, ensemble_logits.shape[-1]), dim=-1).indices
ensemble_correct = ensemble_prediction.eq(targets)
oracle_correct = correctness.any(dim=0)
unanimous_wrong = correctness.logical_not().all(dim=0)
unanimous_same_prediction = predictions.eq(predictions[:1]).all(dim=0)
shape_families = tuple(shape_family(str(label)) for label in labels)
visual_families = tuple(visual_label_family(str(label)) for label in labels)
shape_correct = torch.tensor([
shape_families[truth] == shape_families[predicted]
for truth, predicted in zip(targets.tolist(), ensemble_prediction.tolist(), strict=True)
])
visual_correct = torch.tensor([
visual_families[truth] == visual_families[predicted]
for truth, predicted in zip(targets.tolist(), ensemble_prediction.tolist(), strict=True)
])
exact_wrong = ensemble_correct.logical_not()
per_label: dict[str, dict] = {}
confusion = Counter()
for index, label in enumerate(labels):
mask = targets.eq(index)
count = int(mask.sum())
if count == 0:
continue
correct = int(ensemble_correct[mask].sum())
oracle = int(oracle_correct[mask].sum())
common = int(unanimous_wrong[mask].sum())
per_label[str(label)] = {
"samples": count,
"ensemble_top1": correct / count,
"seed_oracle_top1": oracle / count,
"unanimous_wrong_rate": common / count,
}
for truth, predicted in zip(targets.tolist(), ensemble_prediction.tolist(), strict=True):
if truth != predicted:
confusion[(str(labels[truth]), str(labels[predicted]))] += 1
def _slice_rows(keys: Sequence[str | int]) -> dict[str, dict]:
"""ํ•„์š” ๋ณ€์ˆ˜: source ๋˜๋Š” writer key. ์ž‘๋™ ์›๋ฆฌ: ๋™์ผ ensemble ์ง€ํ‘œ๋ฅผ slice๋ณ„๋กœ ์ง‘๊ณ„ํ•œ๋‹ค."""
grouped: dict[str, list[int]] = defaultdict(list)
for index, key in enumerate(keys):
grouped[str(key)].append(index)
rows = {}
for key, indices in grouped.items():
mask = torch.tensor(indices, dtype=torch.long)
count = len(indices)
rows[key] = {
"samples": count,
"ensemble_top1": float(ensemble_correct[mask].float().mean()),
"seed_oracle_top1": float(oracle_correct[mask].float().mean()),
"unanimous_wrong_rate": float(unanimous_wrong[mask].float().mean()),
}
return rows
per_writer = _slice_rows(writers.tolist())
eligible_writers = [
row["ensemble_top1"] for row in per_writer.values() if int(row["samples"]) >= 10
]
weakest_labels = sorted(
(
{"label": label, **row}
for label, row in per_label.items() if int(row["samples"]) >= 5
),
key=lambda row: (float(row["ensemble_top1"]), -int(row["samples"]), str(row["label"])),
)[:30]
top_confusions = [
{"truth": truth, "predicted": predicted, "count": count}
for (truth, predicted), count in confusion.most_common(40)
]
return {
"samples": samples,
"seed_count": len(logits_by_seed),
"seed_top1": [
float(correct.float().mean()) for correct in correctness
],
"ensemble_top1": float(ensemble_correct.float().mean()),
"ensemble_top5": float(
ensemble_top5.eq(targets[:, None]).any(dim=-1).float().mean()
),
"shape_family_top1": float(shape_correct.float().mean()),
"visual_family_top1": float(visual_correct.float().mean()),
"exact_error_visual_family_recoverable_rate": float(
visual_correct[exact_wrong].float().mean() if exact_wrong.any() else 0.0
),
"exact_error_visual_family_recoverable_pp": float(
(visual_correct & exact_wrong).float().mean() * 100.0
),
"seed_oracle_top1": float(oracle_correct.float().mean()),
"all_seed_wrong_rate": float(unanimous_wrong.float().mean()),
"all_seed_same_wrong_rate": float(
(unanimous_wrong & unanimous_same_prediction).float().mean()
),
"recoverable_by_seed_choice_pp": float(
(oracle_correct.float().mean() - ensemble_correct.float().mean()) * 100.0
),
"eligible_writer_count": len(eligible_writers),
"eligible_writer_floor": min(eligible_writers, default=0.0),
"per_source": _slice_rows(sources),
"weakest_labels_min5": weakest_labels,
"top_confusions": top_confusions,
}
def _infer_logits06(
base_paths: Sequence[Path],
adapter_paths: Sequence[Path],
features: torch.Tensor,
*,
device: torch.device,
batch_size: int,
) -> tuple[list[torch.Tensor], tuple[str, ...], tuple[str, ...]]:
"""ํ•„์š” ๋ณ€์ˆ˜: composite checkpointยทonline feature. ์ž‘๋™ ์›๋ฆฌ: ๊ฐ seed์˜ exact logit์„ CPU์—์„œ ์ˆ˜์ง‘ํ•œ๋‹ค."""
outputs: list[torch.Tensor] = []
exact_labels: tuple[str, ...] | None = None
family_labels: tuple[str, ...] | None = None
for base_path, adapter_path in zip(base_paths, adapter_paths, strict=True):
model, adapter, base, _adapter_payload = _load_encoder06(
base_path, adapter_path, device,
)
current_exact = tuple(str(value) for value in base["exact_labels"])
current_family = tuple(str(value) for value in base["family_labels"])
if exact_labels is not None and current_exact != exact_labels:
raise ValueError("seed๋ณ„ exact label ์ˆœ์„œ๊ฐ€ ๋‹ค๋ฆ…๋‹ˆ๋‹ค.")
exact_labels, family_labels = current_exact, current_family
rows = []
model.eval()
adapter.eval()
with torch.inference_mode():
for start in range(0, len(features), batch_size):
batch = features[start:start + batch_size].to(device)
exact, _family = model.forward_online(adapter(batch))
rows.append(exact.cpu())
outputs.append(torch.cat(rows))
del model, adapter
if device.type == "cuda":
torch.cuda.empty_cache()
assert exact_labels is not None and family_labels is not None
return outputs, exact_labels, family_labels
def main() -> None:
"""ํ•„์š” ๋ณ€์ˆ˜: CLI ์„ค์ •. ์ž‘๋™ ์›๋ฆฌ: valid composite ์„ธ seed์˜ ํ•ฉ์˜ ์˜ค๋ฅ˜ ๋ณด๊ณ ์„œ๋ฅผ UTF-8 JSON์œผ๋กœ ์ €์žฅํ•œ๋‹ค."""
args = _parse_args()
device = _resolve_device06(args.device)
cache = torch.load(args.feature_cache, map_location="cpu", weights_only=True, mmap=True)
features = cache["features"][:, 0].clone()
targets = cache["targets"].long().clone()
writers = cache.get("writers")
if writers is None:
writers = torch.full((len(targets),), -1, dtype=torch.long)
else:
writers = writers.long().clone()
sources = _source_vector06(len(targets), args.source_counts, args.source_split)
logits, labels, family_labels = _infer_logits06(
args.base_checkpoint, args.adapter_checkpoint, features,
device=device, batch_size=args.batch_size,
)
report = {
"experiment": "MATH-INK-06-ONLINE-ERROR-CONSENSUS-001",
"generated_at": datetime.now(timezone.utc).isoformat(),
"device": str(device),
"feature_cache": str(args.feature_cache),
"base_checkpoints": [str(path) for path in args.base_checkpoint],
"adapter_checkpoints": [str(path) for path in args.adapter_checkpoint],
"label_count": len(labels),
"family_count": len(family_labels),
"metrics": summarize_online_consensus06(
logits, targets, writers, labels, sources,
),
"interpretation": {
"all_seed_wrong": "์„ธ seed๊ฐ€ ๋ชจ๋‘ ํ‹€๋ ค seed ์ฆ๋Œ€๋งŒ์œผ๋กœ ํšŒ๋ณต๋˜์ง€ ์•Š๋Š” ๋ฐ์ดํ„ฐยทํ‘œํ˜„ ๋ณ‘๋ชฉ",
"seed_oracle": "ํ‘œ๋ณธ๋งˆ๋‹ค ์ •๋‹ต์„ ๋‚ธ seed๋ฅผ ์‚ฌํ›„ ์„ ํƒํ•œ ๋น„๋ฐฐํฌ ์ƒํ•œ",
"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(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()