| """실제 P Formula v1만 사용해 0.6 formula-domain residual adapter를 GPU 학습한다.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| from collections import Counter |
| from copy import deepcopy |
| from datetime import datetime, timezone |
| from hashlib import sha256 |
| import json |
| from pathlib import Path |
| import random |
| import sys |
| from typing import Any |
|
|
| import numpy as np |
| import torch |
| from torch import Tensor |
| from torch.utils.data import DataLoader, TensorDataset, WeightedRandomSampler |
|
|
| 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.external_corpus import read_jsonl |
| from math_grid_drawer.research.p_formula_dataset06 import ( |
| PFormulaTensorBatch06, |
| materialize_p_formula_split06, |
| p_formula_release_metrics06, |
| p_formula_seed_gate06, |
| ) |
| from math_grid_drawer.research.p_formula_gate06 import audit_p_formula_records06 |
| from math_grid_drawer.research.skeleton_adapter06 import SkeletonTrajectoryAdapter06 |
| from scripts.audit_math_ink_06_case_context import _load_model06 |
| from scripts.train_math_ink_06_formula_adapter import ( |
| _forward06, |
| _metrics06, |
| _targets06, |
| ) |
|
|
|
|
| def _parse_args() -> argparse.Namespace: |
| """필요 변수: P Formula data·제품 adapter·학습/gate 설정. 작동 원리: 재현 가능한 P-only CLI를 만든다.""" |
|
|
| parser = argparse.ArgumentParser(description="Train Math Ink 0.6 P formula adapter") |
| parser.add_argument("--data", type=Path, required=True) |
| parser.add_argument("--adapter", type=Path, required=True) |
| parser.add_argument("--output", type=Path, required=True) |
| parser.add_argument("--seed", type=int, required=True) |
| parser.add_argument("--epochs", type=int, default=16) |
| parser.add_argument("--batch-size", type=int, default=256) |
| parser.add_argument("--learning-rate", type=float, default=4e-4) |
| parser.add_argument("--weight-decay", type=float, default=2e-3) |
| parser.add_argument("--exact-loss-weight", type=float, default=0.10) |
| parser.add_argument("--context-dropout", type=float, default=0.30) |
| parser.add_argument("--hidden-size", type=int, default=64) |
| parser.add_argument("--patience", type=int, default=4) |
| parser.add_argument("--minimum-independent-sources", type=int, default=2) |
| parser.add_argument("--top1-minimum", type=float, default=0.92) |
| parser.add_argument("--top5-minimum", type=float, default=0.99) |
| parser.add_argument("--macro-f1-minimum", type=float, default=0.90) |
| parser.add_argument("--writer-floor-minimum", type=float, default=0.75) |
| parser.add_argument("--missing-drop-maximum-pp", type=float, default=3.0) |
| parser.add_argument("--skip-test", action="store_true") |
| parser.add_argument("--device", choices=("cuda", "cpu"), default="cuda") |
| return parser.parse_args() |
|
|
|
|
| def _seed06(seed: int) -> None: |
| """필요 변수: seed. 작동 원리: Python·NumPy·PyTorch 난수를 함께 고정한다.""" |
|
|
| random.seed(seed) |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed) |
|
|
|
|
| def _file_sha25606(path: Path) -> str: |
| """필요 변수: P Formula JSONL. 작동 원리: seed 간 동일 corpus를 증명할 byte-level SHA-256을 계산한다.""" |
|
|
| digest = sha256() |
| with path.open("rb") as file: |
| for chunk in iter(lambda: file.read(1024 * 1024), b""): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| def _source_label_loader06( |
| batch: PFormulaTensorBatch06, |
| exact_targets: Tensor, |
| family_targets: Tensor, |
| *, |
| batch_size: int, |
| seed: int, |
| ) -> DataLoader: |
| """필요 변수: 학습 batch·label target·seed. 작동 원리: source와 exact label 빈도를 함께 완화한 sampler를 만든다.""" |
|
|
| label_counts = Counter(int(value) for value in exact_targets.tolist()) |
| source_counts = Counter(batch.source_ids) |
| weights = torch.tensor([ |
| 1.0 |
| / ( |
| max(label_counts[int(label)], 1) ** 0.5 |
| * max(source_counts[source], 1) ** 0.5 |
| ) |
| for label, source in zip( |
| exact_targets.tolist(), |
| batch.source_ids, |
| strict=True, |
| ) |
| ], dtype=torch.float32) |
| weights /= weights.mean().clamp_min(1e-8) |
| sampler = WeightedRandomSampler( |
| weights, |
| num_samples=len(weights), |
| replacement=True, |
| generator=torch.Generator().manual_seed(seed), |
| ) |
| return DataLoader( |
| TensorDataset(batch.features, exact_targets, family_targets), |
| batch_size=batch_size, |
| sampler=sampler, |
| ) |
|
|
|
|
| def _release_metrics06( |
| exact_logits: Tensor, |
| batch: PFormulaTensorBatch06, |
| exact_targets: Tensor, |
| labels: tuple[str, ...], |
| ) -> dict[str, Any]: |
| """필요 변수: exact logits·P batch·targets. 작동 원리: 공통 release metric 호출의 identity 인자를 고정한다.""" |
|
|
| return p_formula_release_metrics06( |
| exact_logits, |
| exact_targets, |
| labels=labels, |
| writer_ids=batch.writer_ids, |
| source_ids=batch.source_ids, |
| timestamp_missing=batch.timestamp_missing, |
| pressure_missing=batch.pressure_missing, |
| ) |
|
|
|
|
| def main() -> None: |
| """필요 변수: P-only split corpus·seed별 product adapter. 작동 원리: validation 선택 후 test를 한 번 평가하고 seed gate를 기록한다.""" |
|
|
| args = _parse_args() |
| if not 0.0 <= args.context_dropout <= 1.0: |
| raise ValueError("context dropout은 0~1 범위여야 합니다.") |
| device = torch.device(args.device) |
| if device.type == "cuda" and not torch.cuda.is_available(): |
| raise RuntimeError("CUDA 학습을 요청했지만 사용할 수 없습니다.") |
| _seed06(args.seed) |
|
|
| records = list(read_jsonl(args.data)) |
| data_sha256 = _file_sha25606(args.data) |
| audit = audit_p_formula_records06( |
| records, |
| minimum_independent_sources=args.minimum_independent_sources, |
| ) |
| if not audit["eligible_for_product_evaluation"]: |
| raise ValueError( |
| "P Formula preflight 실패: " |
| + json.dumps(audit["issues"][:10], ensure_ascii=False), |
| ) |
| adapter_payload = torch.load(args.adapter, map_location="cpu", weights_only=False) |
| base_checkpoint = Path(str(adapter_payload["base_checkpoint"])) |
| if not base_checkpoint.is_absolute(): |
| base_checkpoint = PROJECT_ROOT / base_checkpoint |
| engine, online_adapter = _load_model06(base_checkpoint, args.adapter, device) |
| for parameter in engine.model.parameters(): |
| parameter.requires_grad_(False) |
| for parameter in online_adapter.parameters(): |
| parameter.requires_grad_(False) |
| labels = tuple(str(label) for label in engine.labels) |
| by_split = { |
| split: [record for record in records if str(record["split"]) == split] |
| for split in ("training", "validation", "test") |
| } |
| train_batch = materialize_p_formula_split06( |
| by_split["training"], |
| allowed_labels=labels, |
| ) |
| validation_batch = materialize_p_formula_split06( |
| by_split["validation"], |
| allowed_labels=labels, |
| ) |
| test_batch = ( |
| None |
| if args.skip_test |
| else materialize_p_formula_split06(by_split["test"], allowed_labels=labels) |
| ) |
| train_exact, train_family = _targets06( |
| train_batch.truths, |
| engine.labels, |
| engine.family_labels, |
| ) |
| validation_exact, validation_family = _targets06( |
| validation_batch.truths, |
| engine.labels, |
| engine.family_labels, |
| ) |
| test_exact, test_family = ( |
| _targets06(test_batch.truths, engine.labels, engine.family_labels) |
| if test_batch is not None |
| else (None, None) |
| ) |
| loader = _source_label_loader06( |
| train_batch, |
| train_exact, |
| train_family, |
| batch_size=args.batch_size, |
| seed=args.seed, |
| ) |
|
|
| formula_adapter = SkeletonTrajectoryAdapter06( |
| hidden_size=args.hidden_size, |
| ).to(device) |
| optimizer = torch.optim.AdamW( |
| formula_adapter.parameters(), |
| lr=args.learning_rate, |
| weight_decay=args.weight_decay, |
| ) |
| scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( |
| optimizer, |
| T_max=max(args.epochs, 1), |
| eta_min=args.learning_rate * 0.1, |
| ) |
| baseline_logits = _forward06( |
| engine.model, |
| online_adapter, |
| torch.nn.Identity().to(device), |
| validation_batch.features, |
| device=device, |
| batch_size=args.batch_size, |
| ) |
| baseline_validation = _metrics06( |
| *baseline_logits, |
| validation_exact, |
| validation_family, |
| engine.labels, |
| ) |
| best_key = (-1.0, -1.0) |
| best_state: dict[str, Tensor] | None = None |
| best_epoch = 0 |
| stale = 0 |
| history = [] |
| for epoch in range(1, args.epochs + 1): |
| formula_adapter.train() |
| losses = [] |
| for features, exact_target, family_target in loader: |
| features = features.to(device) |
| exact_target = exact_target.to(device) |
| family_target = family_target.to(device) |
| if args.context_dropout: |
| drop = torch.rand(len(features), device=device) < args.context_dropout |
| features = features.clone() |
| features[drop, :, 10:15] = 0.0 |
| optimizer.zero_grad(set_to_none=True) |
| with torch.no_grad(): |
| online = online_adapter(features) |
| exact_logits, family_logits = engine.model.classify_trajectory( |
| formula_adapter(online), |
| ) |
| loss = ( |
| torch.nn.functional.cross_entropy(family_logits, family_target) |
| + args.exact_loss_weight |
| * torch.nn.functional.cross_entropy(exact_logits, exact_target) |
| ) |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_(formula_adapter.parameters(), 2.0) |
| optimizer.step() |
| losses.append(float(loss.detach())) |
| scheduler.step() |
| validation_logits = _forward06( |
| engine.model, |
| online_adapter, |
| formula_adapter, |
| validation_batch.features, |
| device=device, |
| batch_size=args.batch_size, |
| ) |
| validation_metrics = _metrics06( |
| *validation_logits, |
| validation_exact, |
| validation_family, |
| engine.labels, |
| ) |
| row = { |
| "epoch": epoch, |
| "loss": sum(losses) / max(len(losses), 1), |
| "validation": validation_metrics, |
| } |
| history.append(row) |
| print(json.dumps(row, ensure_ascii=False), flush=True) |
| key = ( |
| float(validation_metrics["family_head_top1"]), |
| float(validation_metrics["visual_family_top1"]), |
| ) |
| if key > best_key: |
| best_key = key |
| best_epoch = epoch |
| best_state = deepcopy({ |
| name: value.detach().cpu() |
| for name, value in formula_adapter.state_dict().items() |
| }) |
| stale = 0 |
| else: |
| stale += 1 |
| if stale >= args.patience: |
| break |
| if best_state is None: |
| raise RuntimeError("P formula adapter checkpoint가 선택되지 않았습니다.") |
| formula_adapter.load_state_dict(best_state) |
| selected_validation_logits = _forward06( |
| engine.model, |
| online_adapter, |
| formula_adapter, |
| validation_batch.features, |
| device=device, |
| batch_size=args.batch_size, |
| ) |
| selected_validation = _release_metrics06( |
| selected_validation_logits[0], |
| validation_batch, |
| validation_exact, |
| labels, |
| ) |
| if test_batch is not None and test_exact is not None and test_family is not None: |
| test_logits = _forward06( |
| engine.model, |
| online_adapter, |
| formula_adapter, |
| test_batch.features, |
| device=device, |
| batch_size=args.batch_size, |
| ) |
| official_test = _release_metrics06( |
| test_logits[0], |
| test_batch, |
| test_exact, |
| labels, |
| ) |
| seed_gate = p_formula_seed_gate06( |
| official_test, |
| top1_minimum=args.top1_minimum, |
| top5_minimum=args.top5_minimum, |
| macro_f1_minimum=args.macro_f1_minimum, |
| writer_floor_minimum=args.writer_floor_minimum, |
| missing_drop_maximum_pp=args.missing_drop_maximum_pp, |
| ) |
| else: |
| official_test = None |
| seed_gate = None |
|
|
| args.output.mkdir(parents=True, exist_ok=True) |
| checkpoint = args.output / "p_formula_adapter.pt" |
| torch.save({ |
| "schema": "aiflow-math-ink-06-p-formula-adapter-v1", |
| "state_dict": best_state, |
| "hidden_size": args.hidden_size, |
| "base_checkpoint": str(base_checkpoint), |
| "online_adapter": str(args.adapter), |
| "selected_epoch": best_epoch, |
| "context_dropout": args.context_dropout, |
| "exact_loss_weight": args.exact_loss_weight, |
| "track": "P_approved_formula_only", |
| "seed_gate_passed": bool(seed_gate and seed_gate["passed"]), |
| "product_validation": False, |
| "distillation_allowed": False, |
| "data_sha256": data_sha256, |
| }, checkpoint) |
| report = { |
| "experiment": "P-MATH-INK-06-FORMULA-ADAPTER-001", |
| "generated_at": datetime.now(timezone.utc).isoformat(), |
| "seed": args.seed, |
| "device": str(device), |
| "cuda_device": ( |
| torch.cuda.get_device_name(device) if device.type == "cuda" else None |
| ), |
| "data": str(args.data), |
| "data_sha256": data_sha256, |
| "preflight": audit, |
| "samples": { |
| "training": len(train_batch.truths), |
| "validation": len(validation_batch.truths), |
| "test": 0 if test_batch is None else len(test_batch.truths), |
| }, |
| "label_support": { |
| "training": len(set(train_batch.truths)), |
| "validation": len(set(validation_batch.truths)), |
| "test": 0 if test_batch is None else len(set(test_batch.truths)), |
| }, |
| "sampler": "inverse_sqrt_source_x_exact_label", |
| "baseline_validation": baseline_validation, |
| "selected_epoch": best_epoch, |
| "selected_validation": selected_validation, |
| "official_test": official_test, |
| "seed_gate": seed_gate, |
| "official_test_skipped": args.skip_test, |
| "history": history, |
| "checkpoint": checkpoint.name, |
| "checkpoint_bytes": checkpoint.stat().st_size, |
| "track": "P_approved_formula_only", |
| "product_validation": False, |
| "distillation_allowed": False, |
| "next_gate": "seeds 17/31/47 individual pass, then single student distillation and Android LiteRT validation", |
| } |
| (args.output / "report.json").write_text( |
| json.dumps(report, ensure_ascii=False, indent=2) + "\n", |
| encoding="utf-8", |
| ) |
| print(json.dumps({ |
| "seed": args.seed, |
| "selected_epoch": best_epoch, |
| "validation": selected_validation, |
| "official_test": official_test, |
| "seed_gate": seed_gate, |
| "checkpoint": str(checkpoint), |
| "product_validation": False, |
| }, ensure_ascii=False, indent=2)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|