Files
AlphaMaster/web/training_package.py
2026-07-10 18:14:59 +08:00

217 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Export / import training checkpoints as portable zip packages."""
from __future__ import annotations
import io
import json
import re
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import torch
from model_core.vocab import FORMULA_VOCAB
from web.progress import (
CHECKPOINT_DIR,
PROJECT_ROOT,
_decode_formula,
_load_strategy,
checkpoint_glob,
invalidate_checkpoint_cache,
)
_CKPT_NAME_RE = re.compile(r"^ckpt_(.+)_step_(\d+)\.pt$", re.IGNORECASE)
def _history_path(symbol: str) -> Path:
return PROJECT_ROOT / f"training_history_{symbol}.json"
def _symbol_from_ckpt_name(name: str) -> str | None:
m = _CKPT_NAME_RE.match(Path(name).name)
return m.group(1) if m else None
def _validate_checkpoint_file(path: Path) -> dict[str, Any]:
ckpt = torch.load(path, map_location="cpu", weights_only=False)
artifact_version = ckpt.get("vocab_version")
if artifact_version is None:
raise ValueError(
f"检查点 {path.name} 过旧(无 vocab_version),"
f"当前词表 {FORMULA_VOCAB.version!r},请重新训练"
)
FORMULA_VOCAB.verify(artifact_version)
step = int(ckpt.get("step", 0))
symbol = ckpt.get("symbol")
return {"step": step, "symbol": symbol, "best_score": ckpt.get("best_score")}
def build_training_export_zip(symbol: str) -> tuple[bytes, str]:
ckpts = checkpoint_glob(symbol)
if not ckpts:
raise FileNotFoundError(f"未找到 {symbol} 的训练检查点,请先训练并保存 checkpoint")
latest = ckpts[-1]
_validate_checkpoint_file(latest)
strategy = _load_strategy(symbol)
history_path = _history_path(symbol)
step = int(re.search(r"_step_(\d+)\.pt$", latest.name).group(1))
safe = symbol.replace(".", "_")
zip_name = f"training_{safe}_step{step:04d}.zip"
manifest = {
"format": "alphamaster_training_v1",
"symbol": symbol,
"step": step,
"checkpoint": f"checkpoints/{latest.name}",
"exported_at": datetime.now(timezone.utc).isoformat(),
"files": [f"checkpoints/{latest.name}"],
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(latest, f"checkpoints/{latest.name}")
if strategy:
strat_name = f"strategies/best_{symbol}.json"
payload = dict(strategy)
if payload.get("formula") and not payload.get("formula_decoded"):
payload["formula_decoded"] = _decode_formula(payload["formula"])
zf.writestr(strat_name, json.dumps(payload, ensure_ascii=False, indent=2))
manifest["files"].append(strat_name)
if history_path.exists():
hist_name = f"training_history_{symbol}.json"
zf.write(history_path, hist_name)
manifest["files"].append(hist_name)
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
return buf.getvalue(), zip_name
def _remove_symbol_checkpoints(symbol: str) -> int:
removed = 0
for path in checkpoint_glob(symbol):
path.unlink(missing_ok=True)
removed += 1
return removed
def import_training_package(
content: bytes,
filename: str,
expected_symbol: str | None = None,
) -> dict[str, Any]:
name = Path(filename).name.lower()
installed: list[str] = []
symbol: str | None = None
step: int | None = None
if name.endswith(".zip"):
result = _import_zip(content, expected_symbol)
symbol = result["symbol"]
step = result["step"]
installed = result["installed"]
elif name.endswith(".pt"):
result = _import_pt(content, filename, expected_symbol)
symbol = result["symbol"]
step = result["step"]
installed = result["installed"]
else:
raise ValueError("仅支持 .zip 训练包或 .pt 检查点文件")
invalidate_checkpoint_cache()
return {
"ok": True,
"symbol": symbol,
"step": step,
"installed": installed,
"message": f"已导入 {symbol} 的训练文件(step {step}),下次训练将从断点续训",
}
def _import_zip(content: bytes, expected_symbol: str | None) -> dict[str, Any]:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
names = [n for n in zf.namelist() if not n.endswith("/")]
if "manifest.json" not in names:
raise ValueError("训练包缺少 manifest.json")
manifest = json.loads(zf.read("manifest.json").decode("utf-8"))
if manifest.get("format") != "alphamaster_training_v1":
raise ValueError("不支持的训练包格式")
symbol = manifest.get("symbol") or ""
ckpt_rel = manifest.get("checkpoint", "")
if not symbol or not ckpt_rel:
raise ValueError("训练包 manifest 不完整")
if expected_symbol and symbol != expected_symbol:
raise ValueError(
f"训练包品种为 {symbol},与当前选择的 {expected_symbol} 不一致"
)
_remove_symbol_checkpoints(symbol)
installed: list[str] = []
imported_history = False
for member in names:
if member == "manifest.json":
continue
dest = PROJECT_ROOT / member.replace("/", "\\") if "\\" in str(PROJECT_ROOT) else PROJECT_ROOT / member
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(member))
installed.append(str(dest.relative_to(PROJECT_ROOT)).replace("\\", "/"))
if member.endswith(".pt"):
_validate_checkpoint_file(dest)
if member == f"training_history_{symbol}.json":
imported_history = True
# 若 zip 不包含训练曲线,项目中可能残留更“新”的 history,导致 UI 显示步数不变。
# 这类 zip 通常只有 checkpoint + strategy(例如只想迁移断点),因此应清掉旧曲线以与 checkpoint 对齐。
if not imported_history:
try:
_history_path(symbol).unlink(missing_ok=True)
except OSError:
pass
step = int(manifest.get("step") or 0)
return {"symbol": symbol, "step": step, "installed": installed}
def _import_pt(content: bytes, filename: str, expected_symbol: str | None) -> dict[str, Any]:
ckpt_name = Path(filename).name
symbol = _symbol_from_ckpt_name(ckpt_name)
if not symbol:
raise ValueError(f"无法从文件名识别品种: {ckpt_name}(须为 ckpt_品种_step_XXXX.pt)")
if expected_symbol and symbol != expected_symbol:
raise ValueError(
f"检查点品种为 {symbol},与当前选择的 {expected_symbol} 不一致"
)
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
dest = CHECKPOINT_DIR / ckpt_name
# 只导入 .pt 时,项目里可能还残留更“新”的 training_history_{symbol}.json。
# Web 进度会优先使用该曲线文件的最后一步,导致显示步数大于 checkpoint。
# 因此这里主动清掉旧曲线,避免「导入 60 步却显示 84」。
try:
_history_path(symbol).unlink(missing_ok=True)
except OSError:
pass
_remove_symbol_checkpoints(symbol)
dest.write_bytes(content)
meta = _validate_checkpoint_file(dest)
return {
"symbol": symbol,
"step": meta["step"],
"installed": [str(dest.relative_to(PROJECT_ROOT)).replace("\\", "/")],
}