| """승인된 P-track source를 rehearsal해 0.6 shared online encoder의 source 일반화를 개선한다.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| from collections import Counter |
| from hashlib import sha256 |
| import json |
| import random |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| from torch import nn |
| 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 math_grid_drawer.research.ink06_federation import ( |
| CrossSourceLabelBatchSampler06, FederatedPairedInk06Dataset, augment_online_features06, |
| cross_source_supervised_contrastive_loss06, federation_provenance06, interpolate_state_dict06, load_product_federation06, |
| resolve_training_device06, source_label_balanced_sampler06, |
| ) |
| from math_grid_drawer.research.math_ink_06 import MathInk06Engine, fuse_hypothesis_class_logits06 |
| from train_math_ink_06_candidate import _balanced_subset |
| from train_math_ink_06_virtual_adapter import _fused_logits |
|
|
|
|
| def _partition(record: dict, buckets: int = 5) -> int: |
| """필요 변수: writer 또는 origin. 작동 원리: writer가 있으면 writer 단위, 없으면 origin 단위의 안정 partition을 만든다.""" |
|
|
| key = str(record.get("writer_key") or record.get("origin_id") or record["sample_id"]) |
| return int.from_bytes(sha256(key.encode("utf-8")).digest()[:8], "big") % buckets |
|
|
|
|
| def _source_subset(records: list[dict], maximum: int, seed: int) -> list[dict]: |
| """필요 변수: 한 source record. 작동 원리: label round-robin으로 희귀 class를 보존한다.""" |
|
|
| return _balanced_subset(records, maximum, seed) |
|
|
|
|
| def _parse_source_train_caps06(value: str) -> dict[str, int]: |
| """필요 변수: `source_id=count` CSV. 작동 원리: 공통 상한으로 재현할 수 없는 source별 training cap을 명시적으로 검증해 반환한다.""" |
|
|
| if not value.strip(): |
| return {} |
| output: dict[str, int] = {} |
| for item in value.split(","): |
| source_id, separator, raw_count = item.strip().partition("=") |
| if not separator or not source_id or not raw_count.isdigit() or int(raw_count) <= 0: |
| raise ValueError("source train cap은 `source_id=양의정수` CSV여야 합니다.") |
| if source_id in output: |
| raise ValueError(f"source train cap이 중복되었습니다: {source_id}") |
| output[source_id] = int(raw_count) |
| return output |
|
|
|
|
| def _training_manifest06(records: list[dict]) -> dict[str, object]: |
| """필요 변수: 최종 선택된 training record. 작동 원리: source별 실제 수와 source/sample/origin 식별자의 SHA-256을 고정해 동일 count의 다른 표본 재학습을 막는다.""" |
|
|
| rows = sorted( |
| f"{row['source']}\x1f{row['sample_id']}\x1f{row.get('origin_id', '')}" |
| for row in records |
| ) |
| digest = sha256("\n".join(rows).encode("utf-8")).hexdigest() |
| return { |
| "samples": len(records), |
| "source_counts": dict(sorted(Counter(str(row["source"]) for row in records).items())), |
| "sample_origin_sha256": digest, |
| } |
|
|
|
|
| def _evaluate_source( |
| engine: MathInk06Engine, records: list[dict], exact_to_index: dict[str, int], |
| family_to_index: dict[str, int], batch_size: int, |
| ) -> dict[str, float]: |
| """필요 변수: source별 고정 record. 작동 원리: online과 raster top-k를 동시에 측정한다.""" |
|
|
| loader = DataLoader( |
| FederatedPairedInk06Dataset(records, exact_to_index, family_to_index), |
| batch_size=batch_size, shuffle=False, num_workers=0, |
| ) |
| totals = {"samples": 0, "online_top1": 0, "online_top5": 0, "raster_top1": 0, "raster_top5": 0} |
| engine.model.eval() |
| with torch.inference_mode(): |
| for online, raster, _coordinates, _states, target, _family, _source in loader: |
| online, raster, target = online.to(engine.device), raster.to(engine.device), target.to(engine.device) |
| online_logits, _online_family = engine.model.forward_online(online) |
| raster_output = engine.model.forward_raster(raster) |
| raster_logits = _fused_logits(engine, raster_output) |
| totals["samples"] += len(target) |
| totals["online_top1"] += int((online_logits.argmax(dim=1) == target).sum()) |
| totals["online_top5"] += int((online_logits.topk(5, dim=1).indices == target[:, None]).any(dim=1).sum()) |
| totals["raster_top1"] += int((raster_logits.argmax(dim=1) == target).sum()) |
| totals["raster_top5"] += int((raster_logits.topk(5, dim=1).indices == target[:, None]).any(dim=1).sum()) |
| count = max(int(totals.pop("samples")), 1) |
| return {"samples": count, **{key: value / count for key, value in totals.items()}} |
|
|
|
|
| def _evaluate_sources( |
| engine: MathInk06Engine, groups: dict[str, list[dict]], exact_to_index: dict[str, int], |
| family_to_index: dict[str, int], batch_size: int, |
| ) -> dict[str, dict[str, float]]: |
| """필요 변수: source→record. 작동 원리: source별 분리 지표와 macro online top-1을 반환한다.""" |
|
|
| return { |
| source_id: _evaluate_source(engine, records, exact_to_index, family_to_index, batch_size) |
| for source_id, records in groups.items() |
| } |
|
|
|
|
| def _macro_online(metrics: dict[str, dict[str, float]]) -> float: |
| """필요 변수: source별 지표. 작동 원리: 표본 수와 무관한 source macro online top-1을 계산한다.""" |
|
|
| return float(np.mean([row["online_top1"] for row in metrics.values()])) |
|
|
|
|
| def _file_sha25606(path: Path) -> str: |
| """필요 변수: UTF-8과 무관한 checkpoint byte 경로. 작동 원리: resume이 다른 base 모델을 섞지 않도록 SHA-256을 계산한다.""" |
|
|
| digest = sha256() |
| with path.open("rb") as stream: |
| for chunk in iter(lambda: stream.read(1024 * 1024), b""): |
| digest.update(chunk) |
| return digest.hexdigest() |
|
|
|
|
| def _resume_contract06( |
| checkpoint: Path, *, seed: int, source_ids: list[str], train_samples: int, |
| max_train_per_source: int, max_validation_per_source: int, max_test_per_source: int, |
| cross_source_pair_fraction: float = 0.0, cross_source_contrastive_weight: float = 0.0, |
| cross_source_temperature: float = 0.20, source_train_caps: dict[str, int] | None = None, |
| training_sample_origin_sha256: str = "", |
| ) -> dict[str, object]: |
| """필요 변수: base checkpoint·고정 source split·sampling/loss 설정. 작동 원리: epoch resume이 다른 data 또는 cross-source 정렬 계약을 이어붙이는 일을 fail-closed로 막는다.""" |
|
|
| return { |
| "schema_version": "aiflow-math-ink-0.6-federated-online-resume1", |
| "base_checkpoint_sha256": _file_sha25606(checkpoint), |
| "seed": seed, |
| "source_ids": sorted(source_ids), |
| "train_samples": train_samples, |
| "max_train_per_source": max_train_per_source, |
| "max_validation_per_source": max_validation_per_source, |
| "max_test_per_source": max_test_per_source, |
| "cross_source_pair_fraction": cross_source_pair_fraction, |
| "cross_source_contrastive_weight": cross_source_contrastive_weight, |
| "cross_source_temperature": cross_source_temperature, |
| "source_train_caps": dict(sorted((source_train_caps or {}).items())), |
| "training_sample_origin_sha256": training_sample_origin_sha256, |
| } |
|
|
|
|
| def _restore_optimizer_device06(optimizer: torch.optim.Optimizer, device: torch.device) -> None: |
| """필요 변수: CPU로 저장된 optimizer state·실행 device. 작동 원리: resume 뒤 Adam moment를 parameter와 같은 device로 되돌린다.""" |
|
|
| for state in optimizer.state.values(): |
| for key, value in state.items(): |
| if isinstance(value, torch.Tensor): |
| state[key] = value.to(device) |
|
|
|
|
| def _save_epoch_state06( |
| path: Path, *, contract: dict[str, object], epoch: int, student_state: dict[str, torch.Tensor], |
| best_state: dict[str, torch.Tensor], anchor_state: dict[str, torch.Tensor], optimizer: torch.optim.Optimizer, |
| best_metrics: dict[str, dict[str, float]], best_macro: float, best_epoch: int, best_alpha: float, |
| history: list[dict], sampler: object, |
| ) -> None: |
| """필요 변수: epoch 모델·선택 state·sampler/RNG. 작동 원리: 다음 실행이 같은 sampler 순서와 augmentation 난수에서 정확히 이어지게 저장한다.""" |
|
|
| generator = getattr(sampler, "generator", None) |
| sampler_state = generator.get_state() if isinstance(generator, torch.Generator) else None |
| payload = { |
| "schema_version": "aiflow-math-ink-0.6-federated-online-epoch-state1", |
| "contract": contract, |
| "last_epoch": epoch, |
| "student_state": {key: value.detach().cpu().clone() for key, value in student_state.items()}, |
| "best_state": {key: value.detach().cpu().clone() for key, value in best_state.items()}, |
| "anchor_state": {key: value.detach().cpu().clone() for key, value in anchor_state.items()}, |
| "optimizer_state": optimizer.state_dict(), |
| "best_metrics": best_metrics, |
| "best_macro": best_macro, |
| "best_epoch": best_epoch, |
| "best_alpha": best_alpha, |
| "history": history, |
| "sampler_state": sampler_state, |
| "python_rng_state": random.getstate(), |
| "numpy_rng_state": np.random.get_state(), |
| "torch_rng_state": torch.get_rng_state(), |
| "cuda_rng_state": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None, |
| } |
| path.parent.mkdir(parents=True, exist_ok=True) |
| torch.save(payload, path) |
|
|
|
|
| def _restore_epoch_state06( |
| path: Path, *, contract: dict[str, object], student: MathInk06Engine, |
| optimizer: torch.optim.Optimizer, sampler: object, |
| ) -> dict: |
| """필요 변수: epoch-state·현재 data contract·student/optimizer. 작동 원리: contract 일치 때만 파라미터·optimizer·난수를 원자적으로 복원한다.""" |
|
|
| payload = torch.load(path, map_location="cpu", weights_only=False) |
| if payload.get("schema_version") != "aiflow-math-ink-0.6-federated-online-epoch-state1": |
| raise ValueError("지원하지 않는 federated online epoch-state입니다.") |
| if payload.get("contract") != contract: |
| raise ValueError("resume epoch-state의 checkpoint/source/split 계약이 현재 실행과 다릅니다.") |
| student.model.load_state_dict(payload["student_state"]) |
| optimizer.load_state_dict(payload["optimizer_state"]) |
| _restore_optimizer_device06(optimizer, student.device) |
| generator = getattr(sampler, "generator", None) |
| sampler_state = payload.get("sampler_state") |
| if isinstance(generator, torch.Generator) and isinstance(sampler_state, torch.Tensor): |
| generator.set_state(sampler_state) |
| random.setstate(payload["python_rng_state"]) |
| np.random.set_state(payload["numpy_rng_state"]) |
| torch.set_rng_state(payload["torch_rng_state"]) |
| if torch.cuda.is_available() and payload.get("cuda_rng_state") is not None: |
| torch.cuda.set_rng_state_all(payload["cuda_rng_state"]) |
| return payload |
|
|
|
|
| def main() -> None: |
| """필요 변수: base 0.6·승인 source. 작동 원리: 명시적 writer split을 우선한 distillation rehearsal과 holdout 평가를 수행한다.""" |
|
|
| parser = argparse.ArgumentParser(description="Train Math Ink 0.6 federated online rehearsal") |
| 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("--max-train-per-source", type=int, default=2000) |
| parser.add_argument("--max-validation-per-source", type=int, default=300) |
| parser.add_argument("--max-test-per-source", type=int, default=500) |
| parser.add_argument("--batch-size", type=int, default=32) |
| parser.add_argument("--epochs", type=int, default=3) |
| parser.add_argument("--learning-rate", type=float, default=2e-5) |
| parser.add_argument("--distillation-weight", type=float, default=0.75) |
| parser.add_argument("--raster-distillation-weight", type=float, default=1.0) |
| parser.add_argument("--raster-supervised-weight", type=float, default=0.0) |
| parser.add_argument("--raster-family-supervised-weight", type=float, default=0.0) |
| parser.add_argument("--hwrt-tolerance", type=float, default=0.01) |
| parser.add_argument("--interpolation-alphas", default="0.25,0.5,0.75,1.0") |
| parser.add_argument("--seed", type=int, default=17) |
| parser.add_argument("--device", default="auto", help="auto|cpu|cuda[:index]") |
| parser.add_argument("--online-rotation-degrees", type=float, default=0.0) |
| parser.add_argument("--online-scale-jitter", type=float, default=0.0) |
| parser.add_argument("--cross-source-pair-fraction", type=float, default=0.0) |
| parser.add_argument("--cross-source-contrastive-weight", type=float, default=0.0) |
| parser.add_argument("--cross-source-temperature", type=float, default=0.20) |
| parser.add_argument("--source-train-caps", default="", help="선택 source별 training cap: source_id=count,...") |
| parser.add_argument("--resume-state", type=Path, help="이전 epoch-state에서 안전하게 재개한다.") |
| parser.add_argument( |
| "--save-epoch-state", action=argparse.BooleanOptionalAction, default=True, |
| help="각 epoch의 재개 가능한 state를 저장한다.", |
| ) |
| args = parser.parse_args() |
| if args.raster_supervised_weight < 0 or args.raster_family_supervised_weight < 0: |
| raise ValueError("raster supervised weight는 0 이상이어야 합니다.") |
| if args.online_rotation_degrees < 0 or args.online_scale_jitter < 0: |
| raise ValueError("online augmentation 범위는 0 이상이어야 합니다.") |
| if not 0.0 <= args.cross_source_pair_fraction <= 1.0: |
| raise ValueError("cross-source pair 비율은 0과 1 사이여야 합니다.") |
| if args.cross_source_contrastive_weight < 0.0 or args.cross_source_temperature <= 0.0: |
| raise ValueError("cross-source contrastive weight는 0 이상, temperature는 양수여야 합니다.") |
| if args.cross_source_contrastive_weight > 0.0 and args.cross_source_pair_fraction <= 0.0: |
| raise ValueError("contrastive loss에는 0보다 큰 cross-source pair 비율이 필요합니다.") |
| source_train_caps = _parse_source_train_caps06(args.source_train_caps) |
|
|
| random.seed(args.seed) |
| np.random.seed(args.seed) |
| torch.manual_seed(args.seed) |
| device = resolve_training_device06(args.device) |
| student = MathInk06Engine(args.checkpoint, device=device) |
| teacher = MathInk06Engine(args.checkpoint, device=str(student.device)) |
| print(json.dumps({"device": str(student.device), "cuda": torch.cuda.is_available()}), flush=True) |
| for parameter in student.model.raster_encoder.parameters(): |
| parameter.requires_grad_(False) |
| for parameter in student.model.virtual_decoder.parameters(): |
| parameter.requires_grad_(False) |
| if student.model.auxiliary_virtual_decoder is not None: |
| for parameter in student.model.auxiliary_virtual_decoder.parameters(): |
| parameter.requires_grad_(False) |
| for parameter in student.model.virtual_adapter.parameters(): |
| parameter.requires_grad_(False) |
| trainable = [ |
| *student.model.trajectory_encoder.parameters(), *student.model.exact_head.parameters(), |
| *student.model.family_head.parameters(), |
| ] |
| exact_to_index = {label: index for index, label in enumerate(student.labels)} |
| family_to_index = {label: index for index, label in enumerate(student.family_labels)} |
| sources = load_product_federation06( |
| registry_path=args.registry, commercial_path=args.commercial, hwrt_path=args.hwrt, |
| approval_path=args.approval, allowed_labels=student.labels, source_registry_path=args.source_registry, |
| ) |
| training_records = [] |
| validation_groups = {} |
| test_groups = {} |
| for source_index, source in enumerate(sources): |
| eligible = [row for row in source.records if row.get("eligible_for_training")] |
| explicit_validation = [row for row in source.records if str(row.get("split")) in {"validation", "valid", "val"}] |
| if explicit_validation: |
| train_candidates = eligible |
| validation_candidates = explicit_validation |
| else: |
| train_candidates = [row for row in eligible if _partition(row) >= 2] |
| validation_candidates = [row for row in eligible if _partition(row) == 0] |
| training_records.extend(_source_subset( |
| train_candidates, source_train_caps.get(source.source_id, args.max_train_per_source), args.seed + source_index, |
| )) |
| validation_groups[source.source_id] = _source_subset( |
| validation_candidates, args.max_validation_per_source, args.seed + 20 + source_index, |
| ) |
| test_candidates = [row for row in source.records if row.get("split") == "test"] |
| test_groups[source.source_id] = _source_subset( |
| test_candidates, args.max_test_per_source, args.seed + 40 + source_index, |
| ) |
| if any(not rows for rows in validation_groups.values()) or any(not rows for rows in test_groups.values()): |
| raise ValueError("source validation/test partition이 비었습니다.") |
| training_manifest = _training_manifest06(training_records) |
| dataset = FederatedPairedInk06Dataset(training_records, exact_to_index, family_to_index) |
| if args.cross_source_pair_fraction > 0.0: |
| epoch_sampler = CrossSourceLabelBatchSampler06( |
| training_records, batch_size=args.batch_size, samples=len(training_records), seed=args.seed, |
| pair_fraction=args.cross_source_pair_fraction, |
| ) |
| loader = DataLoader( |
| dataset, batch_sampler=epoch_sampler, num_workers=0, |
| ) |
| else: |
| sampler = source_label_balanced_sampler06( |
| training_records, seed=args.seed, samples=len(training_records), |
| ) |
| epoch_sampler = sampler |
| loader = DataLoader(dataset, batch_size=args.batch_size, sampler=sampler, num_workers=0) |
| source_to_index = {source_id: index for index, source_id in enumerate(sorted({str(row["source"]) for row in training_records}))} |
| optimizer = torch.optim.AdamW(trainable, lr=args.learning_rate, weight_decay=2e-3) |
| resume_contract = _resume_contract06( |
| args.checkpoint, seed=args.seed, source_ids=list(validation_groups), train_samples=len(training_records), |
| max_train_per_source=args.max_train_per_source, max_validation_per_source=args.max_validation_per_source, |
| max_test_per_source=args.max_test_per_source, |
| cross_source_pair_fraction=args.cross_source_pair_fraction, |
| cross_source_contrastive_weight=args.cross_source_contrastive_weight, |
| cross_source_temperature=args.cross_source_temperature, |
| source_train_caps=source_train_caps, |
| training_sample_origin_sha256=str(training_manifest["sample_origin_sha256"]), |
| ) |
| args.output.mkdir(parents=True, exist_ok=True) |
| baseline_validation = _evaluate_sources( |
| student, validation_groups, exact_to_index, family_to_index, args.batch_size, |
| ) |
| |
| baseline_test = _evaluate_sources( |
| student, test_groups, exact_to_index, family_to_index, args.batch_size, |
| ) |
| best_metrics = baseline_validation |
| best_macro = _macro_online(baseline_validation) |
| best_epoch = 0 |
| best_alpha = 0.0 |
| best_state = {key: value.detach().cpu().clone() for key, value in student.model.state_dict().items()} |
| anchor_state = {key: value.clone() for key, value in best_state.items()} |
| interpolation_alphas = tuple(float(value) for value in args.interpolation_alphas.split(",") if value.strip()) |
| if not interpolation_alphas or any(not 0.0 < value <= 1.0 for value in interpolation_alphas): |
| raise ValueError("interpolation alpha는 0보다 크고 1 이하여야 합니다.") |
| history = [{"epoch": 0, "macro_online_top1": best_macro, "validation": baseline_validation}] |
| start_epoch = 1 |
| if args.resume_state is not None: |
| resumed = _restore_epoch_state06( |
| args.resume_state, contract=resume_contract, student=student, optimizer=optimizer, sampler=epoch_sampler, |
| ) |
| best_state = resumed["best_state"] |
| anchor_state = resumed["anchor_state"] |
| best_metrics = resumed["best_metrics"] |
| best_macro = float(resumed["best_macro"]) |
| best_epoch = int(resumed["best_epoch"]) |
| best_alpha = float(resumed["best_alpha"]) |
| history = list(resumed["history"]) |
| start_epoch = int(resumed["last_epoch"]) + 1 |
| if start_epoch > args.epochs: |
| raise ValueError("resume epoch-state가 요청 epochs보다 이미 앞서 있습니다.") |
| print(json.dumps({"resumed_from": str(args.resume_state), "start_epoch": start_epoch}, ensure_ascii=False), flush=True) |
| temperature = 2.0 |
| for epoch in range(start_epoch, args.epochs + 1): |
| student.model.train() |
| total_loss = seen = contrastive_anchors = 0 |
| for online, raster, _coordinates, _states, exact_target, family_target, source_ids in loader: |
| online, raster = online.to(student.device), raster.to(student.device) |
| exact_target, family_target = exact_target.to(student.device), family_target.to(student.device) |
| optimizer.zero_grad(set_to_none=True) |
| augmented_online = augment_online_features06( |
| online, |
| rotation_degrees=args.online_rotation_degrees, |
| scale_jitter=args.online_scale_jitter, |
| ) |
| embedding = student.model.encode_trajectory(augmented_online) |
| exact, family = student.model.exact_head(embedding), student.model.family_head(embedding) |
| with torch.inference_mode(): |
| teacher_exact, _teacher_family = teacher.model.forward_online(online) |
| teacher_raster = teacher.model.forward_raster(raster)["exact_logits"] |
| supervised = nn.functional.cross_entropy(exact, exact_target, label_smoothing=0.02) |
| supervised = supervised + 0.10 * nn.functional.cross_entropy(family, family_target) |
| distillation = nn.functional.kl_div( |
| nn.functional.log_softmax(exact / temperature, dim=-1), |
| nn.functional.softmax(teacher_exact / temperature, dim=-1), reduction="batchmean", |
| ) * (temperature * temperature) |
| student_raster_output = student.model.forward_raster(raster) |
| student_raster = student_raster_output["exact_logits"] |
| student_raster_fused = _fused_logits(student, student_raster_output) |
| student_raster_family_fused = fuse_hypothesis_class_logits06( |
| student_raster_output["family_logits"], student_raster_output["hypothesis_scores"], |
| mode=str(student.raster_fusion["mode"]), |
| score_weight=float(student.raster_fusion["score_weight"]), |
| ) |
| raster_distillation = nn.functional.kl_div( |
| nn.functional.log_softmax(student_raster / temperature, dim=-1), |
| nn.functional.softmax(teacher_raster / temperature, dim=-1), reduction="batchmean", |
| ) * (temperature * temperature) / student_raster.shape[1] |
| loss = ( |
| supervised + args.distillation_weight * distillation |
| + args.raster_distillation_weight * raster_distillation |
| + args.raster_supervised_weight * nn.functional.cross_entropy( |
| student_raster_fused, exact_target, label_smoothing=0.02, |
| ) |
| + args.raster_family_supervised_weight * nn.functional.cross_entropy( |
| student_raster_family_fused, family_target, label_smoothing=0.02, |
| ) |
| ) |
| if args.cross_source_contrastive_weight > 0.0: |
| source_tensor = torch.tensor( |
| [source_to_index[str(value)] for value in source_ids], dtype=torch.long, device=student.device, |
| ) |
| contrastive, anchors = cross_source_supervised_contrastive_loss06( |
| embedding, exact_target, source_tensor, temperature=args.cross_source_temperature, |
| ) |
| loss = loss + args.cross_source_contrastive_weight * contrastive |
| contrastive_anchors += anchors |
| loss.backward() |
| nn.utils.clip_grad_norm_(trainable, 1.0) |
| optimizer.step() |
| seen += len(exact_target) |
| total_loss += float(loss.detach()) * len(exact_target) |
| trained_state = {key: value.detach().cpu().clone() for key, value in student.model.state_dict().items()} |
| interpolation = [] |
| for alpha in interpolation_alphas: |
| mixed_state = interpolate_state_dict06(anchor_state, trained_state, alpha=alpha) |
| student.model.load_state_dict(mixed_state) |
| metrics = _evaluate_sources( |
| student, validation_groups, exact_to_index, family_to_index, args.batch_size, |
| ) |
| macro = _macro_online(metrics) |
| hwrt_ok = all( |
| metrics["hwrt"][metric] >= baseline_validation["hwrt"][metric] - args.hwrt_tolerance |
| for metric in ("online_top1", "online_top5", "raster_top1", "raster_top5") |
| ) |
| interpolation.append({"alpha": alpha, "macro_online_top1": macro, "hwrt_gate": hwrt_ok, "validation": metrics}) |
| if hwrt_ok and macro > best_macro: |
| best_macro, best_metrics, best_epoch, best_alpha = macro, metrics, epoch, alpha |
| best_state = {key: value.clone() for key, value in mixed_state.items()} |
| student.model.load_state_dict(trained_state) |
| final_row = interpolation[-1] |
| row = { |
| "epoch": epoch, "loss": total_loss / max(seen, 1), "cross_source_contrastive_anchors": contrastive_anchors, |
| "macro_online_top1": final_row["macro_online_top1"], "hwrt_gate": final_row["hwrt_gate"], |
| "validation": final_row["validation"], "interpolation": interpolation, |
| } |
| history.append(row) |
| if args.save_epoch_state: |
| state_path = args.output / f"federated_online_epoch_{epoch:03d}.pt" |
| _save_epoch_state06( |
| state_path, contract=resume_contract, epoch=epoch, student_state=trained_state, |
| best_state=best_state, anchor_state=anchor_state, optimizer=optimizer, |
| best_metrics=best_metrics, best_macro=best_macro, best_epoch=best_epoch, |
| best_alpha=best_alpha, history=history, sampler=epoch_sampler, |
| ) |
| row["resume_state"] = state_path.name |
| print(json.dumps(row, ensure_ascii=False), flush=True) |
| student.model.load_state_dict(best_state) |
| final_test = _evaluate_sources( |
| student, test_groups, exact_to_index, family_to_index, args.batch_size, |
| ) |
| output_payload = torch.load(args.checkpoint, map_location="cpu", weights_only=False) |
| output_payload["state_dict"] = best_state |
| output_payload["model_version"] = "aiflow-math-ink-0.6-federated-online1" |
| provenance = federation_provenance06(sources, args.source_registry) |
| output_payload.update(provenance) |
| output_payload["federated_online"] = { |
| "sources": sorted(validation_groups), "selected_epoch": best_epoch, |
| "seed": args.seed, |
| "selected_interpolation_alpha": best_alpha, |
| "distillation_weight": args.distillation_weight, |
| "raster_distillation_weight": args.raster_distillation_weight, |
| "raster_supervised_weight": args.raster_supervised_weight, |
| "raster_family_supervised_weight": args.raster_family_supervised_weight, |
| "online_rotation_degrees": args.online_rotation_degrees, |
| "online_scale_jitter": args.online_scale_jitter, |
| "cross_source_pair_fraction": args.cross_source_pair_fraction, |
| "cross_source_contrastive_weight": args.cross_source_contrastive_weight, |
| "cross_source_temperature": args.cross_source_temperature, |
| "source_train_caps": source_train_caps, |
| "training_manifest": training_manifest, |
| "resumed_from": str(args.resume_state) if args.resume_state is not None else None, |
| } |
| checkpoint = args.output / "math_ink_06_candidate.pt" |
| torch.save(output_payload, checkpoint) |
| report = { |
| "checkpoint": checkpoint.name, "bytes": checkpoint.stat().st_size, |
| "seed": args.seed, |
| "device": str(student.device), |
| **provenance, |
| "train_samples": len(training_records), "source_count": len(sources), |
| "training_manifest": training_manifest, |
| "baseline_validation": baseline_validation, "selected_validation": best_metrics, |
| "selected_epoch": best_epoch, "selected_interpolation_alpha": best_alpha, |
| "baseline_test": baseline_test, "test": final_test, |
| "test_delta": { |
| source_id: { |
| metric: final_test[source_id][metric] - baseline_test[source_id][metric] |
| for metric in ("online_top1", "online_top5", "raster_top1", "raster_top5") |
| } |
| for source_id in final_test |
| }, |
| "history": history, |
| "hwrt_official_test_used": False, "product_validation": False, |
| } |
| (args.output / "federated_online_report.json").write_text( |
| json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", |
| ) |
| print(json.dumps({key: value for key, value in report.items() if key != "history"}, ensure_ascii=False, indent=2)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|