Files
blackbird/actpred/pretrain_mt.py
T
qing 63cfdd2066 actpred: 两轮代码审计修复与文档合并
- 第 1 轮安全审计修复: F-1~F-10(路径校验、预训练架构修正等)
- 第 2 轮复审修复 11 项: 3 项上轮修复不完整(R01 checkpoint 键校验/
  R02 fused_hidden_dim 值域/R03 pretrained=False)+ 8 项新问题
  (actions.npy 形状校验、帧数警告、CLI 值域校验、stats 形状与路径、
  onnxscript 依赖、归一化语义、解析去重)
- 单元测试由 17 扩展至 51 用例(50 通过 + 1 环境性跳过)
- 审计文档两轮合并至 actpred_CODE_AUDIT_20260808.md,更新 INDEX
2026-08-08 19:37:41 +00:00

283 lines
10 KiB
Python
Raw 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.
"""actpred 预训练入口:从 .mt 脚本学习动作动力学(无需图像)
用法:
bash _run.sh pretrain --mt-dir ../bot4cj/scripts --epochs 200
流程:
1. 解析 .mt 文件中的 @Action 矩阵 → 动作序列
2. 滑动窗口采样: action_history[H] → target action(下一行)
3. 用零图像占位训练完整 ActionPredictor(保持 checkpoint 兼容性)
4. 保存 checkpoint,格式与 train.py 一致,可直接用于推理或微调
数据增强:
--noise-std 控制高斯噪声标准差。因 .mt 脚本数据量有限,
适度噪声(如 0.5~2.0)可有效防止过拟合。
"""
import json
import os
import random
import numpy as np
import torch
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm
from config import Config, build_argparser, apply_overrides, validate_config_values
from model import ActionPredictor
from mt_parser import parse_mt_directory
class MTActionDataset(Dataset):
"""从 .mt @Action 矩阵构建的滑动窗口数据集(无图像)
每个样本: (action_history[history_len, action_dim], target[action_dim])
"""
def __init__(self, mt_dir: str, history_len: int, action_dim: int,
file_indices: list = None, files: list = None):
self.history_len = history_len
self.action_dim = action_dim
self.samples = []
# 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录)
all_files = files if files is not None else parse_mt_directory(mt_dir)
if file_indices is not None:
all_files = [all_files[i] for i in file_indices]
for path, matrix in all_files:
n_cols = matrix.shape[1]
if n_cols < action_dim:
matrix = np.pad(
matrix,
((0, 0), (0, action_dim - n_cols)),
mode="constant",
)
elif n_cols > action_dim:
matrix = matrix[:, :action_dim]
for i in range(len(matrix) - history_len):
history = matrix[i : i + history_len]
target = matrix[i + history_len]
self.samples.append((history.copy(), target.copy()))
print(f"MTActionDataset: {len(self.samples)} samples "
f"from {len(all_files)} files "
f"(history_len={history_len}, action_dim={action_dim})")
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
history, target = self.samples[idx]
return {
"action_history": torch.from_numpy(history.copy()),
"target": torch.from_numpy(target.copy()),
}
def pretrain_one_epoch(model, loader, optimizer, device, image_size,
max_grad_norm=None, noise_std=0.0):
model.train()
total_loss = 0.0
count = 0
zero_image = None
for batch in tqdm(loader, desc=" pretrain", leave=False):
history = batch["action_history"].to(device)
target = batch["target"].to(device)
if noise_std > 0:
history = history + torch.randn_like(history) * noise_std
target = target + torch.randn_like(target) * noise_std
if zero_image is None or zero_image.size(0) != history.size(0):
zero_image = torch.zeros(
history.size(0), 3, image_size, image_size, device=device
)
optimizer.zero_grad()
pred = model(zero_image, history)
loss = torch.nn.functional.mse_loss(pred, target)
loss.backward()
if max_grad_norm:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
optimizer.step()
total_loss += loss.item() * history.size(0)
count += history.size(0)
return total_loss / max(count, 1)
@torch.no_grad()
def validate(model, loader, device, image_size):
model.eval()
total_loss = 0.0
count = 0
zero_image = None
for batch in tqdm(loader, desc=" val ", leave=False):
history = batch["action_history"].to(device)
target = batch["target"].to(device)
if zero_image is None or zero_image.size(0) != history.size(0):
zero_image = torch.zeros(
history.size(0), 3, image_size, image_size, device=device
)
pred = model(zero_image, history)
loss = torch.nn.functional.mse_loss(pred, target)
total_loss += loss.item() * history.size(0)
count += history.size(0)
if count == 0:
return float("inf")
return total_loss / count
def main():
parser = build_argparser("actpred pre-training from .mt scripts")
parser.add_argument("--mt-dir", type=str, required=True,
help=".mt 脚本目录(如 ../bot4cj/scripts)")
parser.add_argument("--noise-std", type=float, default=0.0,
help="高斯噪声标准差(数据增强,建议 0.5~2.0)")
args = parser.parse_args()
cfg = apply_overrides(Config(), args)
validate_config_values(cfg.data, cfg.model, cfg.train)
cfg.data.data_dir = args.mt_dir
os.makedirs(cfg.train.checkpoint_dir, exist_ok=True)
random.seed(cfg.train.seed)
np.random.seed(cfg.train.seed)
torch.manual_seed(cfg.train.seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(cfg.train.seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {device}")
# 按文件级划分 train/val,避免滑动窗口样本重叠导致的数据泄漏
all_files = parse_mt_directory(args.mt_dir)
if len(all_files) == 0:
raise ValueError(
f"数据集为空。请检查 mt-dir={args.mt_dir} "
f"及 .mt 文件中 @Action 行数 > history_len={cfg.data.history_len}"
)
n_files = len(all_files)
file_indices = list(range(n_files))
rng = np.random.RandomState(cfg.train.seed)
rng.shuffle(file_indices)
val_file_count = max(1, int(n_files * cfg.train.val_split))
val_file_indices = file_indices[:val_file_count]
train_file_indices = file_indices[val_file_count:]
train_ds = MTActionDataset(
args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=train_file_indices, files=all_files,
)
val_ds = MTActionDataset(
args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=val_file_indices, files=all_files,
)
print(f"Files: {len(train_file_indices)} train, {len(val_file_indices)} val")
print(f"Samples: {len(train_ds)} train, {len(val_ds)} val")
if len(train_ds) == 0:
raise ValueError("训练集为空(样本数不足)")
if len(val_ds) == 0:
print("WARNING: 验证集为空,模型选择禁用")
loader_gen = torch.Generator()
loader_gen.manual_seed(cfg.train.seed)
train_loader = DataLoader(
train_ds, batch_size=cfg.train.batch_size,
shuffle=True, num_workers=cfg.train.num_workers,
generator=loader_gen,
)
val_loader = DataLoader(
val_ds, batch_size=cfg.train.batch_size,
shuffle=False, num_workers=cfg.train.num_workers,
)
model = ActionPredictor(cfg.model, cfg.data, pretrained=False).to(device)
n_params = sum(p.numel() for p in model.parameters())
print(f"Model params: {n_params:,}")
print(f"Config: action_dim={cfg.data.action_dim}, "
f"history_len={cfg.data.history_len}, "
f"backbone={cfg.model.cnn_backbone}, "
f"encoder={cfg.model.history_encoder}")
print(f"Pre-training: noise_std={args.noise_std}")
optimizer = torch.optim.AdamW(
model.parameters(), lr=cfg.train.lr,
weight_decay=cfg.train.weight_decay,
)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=cfg.train.epochs
)
best_val_loss = float("inf")
patience_counter = 0
best_path = os.path.join(cfg.train.checkpoint_dir, "pretrain_best.pt")
inference_path = os.path.join(
cfg.train.checkpoint_dir, "pretrain_best_inference.pt"
)
for epoch in range(1, cfg.train.epochs + 1):
print(f"\nEpoch {epoch}/{cfg.train.epochs} "
f"lr={optimizer.param_groups[0]['lr']:.6f}")
train_loss = pretrain_one_epoch(
model, train_loader, optimizer, device,
cfg.data.image_size, cfg.train.max_grad_norm, args.noise_std,
)
val_loss = validate(model, val_loader, device, cfg.data.image_size)
scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
patience_counter = 0
torch.save(
{
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"val_loss": val_loss,
"config": cfg.to_dict(),
},
best_path,
)
torch.save(
{
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
},
inference_path,
)
print(f" -> saved best (val_loss={val_loss:.6f})")
else:
patience_counter += 1
if patience_counter >= cfg.train.patience:
print(f"\nEarly stopping at epoch {epoch} "
f"(patience={cfg.train.patience})")
break
print(f"\nPre-training done. Best val_loss={best_val_loss:.6f}")
if best_val_loss == float("inf"):
torch.save(
{
"model_state_dict": model.state_dict(),
"config": cfg.to_dict(),
},
inference_path,
)
print("WARNING: saved final weights (no valid validation loss)")
else:
print(f"Best pre-trained model: {inference_path}")
print("Fine-tune: bash _run.sh train --data-dir ./data "
f"--init-from {inference_path}")
if args.save_config:
with open(args.save_config, "w") as f:
json.dump(cfg.to_dict(), f, indent=2, ensure_ascii=False)
print(f"Config saved to: {args.save_config}")
if __name__ == "__main__":
main()