fix(actpred): 定位 HEF 输入不敏感根因,补齐归一化预训练链路

根因:预训练全零图像占位使 BN running_var 停在初始小值,推理时
特征放大至 ±1e6,DFC 在 token merge 点按最大幅度分配共享量化
scale,pred_token/act_tokens 全部被压成 0,HEF 输出对任何输入
恒定(详见 docs/reports/actpred_hef_insensitivity_rootcause_20260813.md)

- export_deploy.py:新增 check_token_ranges 导出前 token 量级检查
  (|img|>100 中止导出并提示微调;act/img 比例过大警告;归一化
  模型在 z 空间采样,避免零方差列爆炸)
- pretrain_mt.py:新增 --random-images(ImageNet 归一化噪声占位,
  恢复 BN 统计)、--init-from(微调起点,含动作空间一致性警告)、
  --normalize-actions 完整支持(训练集 stats 计算与保存、验证集复用)
- gen_calibration.py:归一化模型的校准集自动 z-score 动作历史
- _onnx_validate.py / _npu_test.py:归一化模型适配(torch 参考
  z-score、HailoPredictor 传 stats_path)
- README:预训练章节补充新参数与 NPU 部署警告
- 单测 76/76 通过;冒烟验证:1 epoch 噪声微调后 |img| 2.46e5→8,
  归一化后 |act| 694→2.9
This commit is contained in:
qing committed 2026-08-13 13:57:38 +08:00
1 parent 8c58770280
commit 52c0ade3c4
8 files changed
+394 -27

No files matched your search

+14 -4
View File
@@ -62,9 +62,7 @@ bash _run.sh pretrain --mt-dir ../bot4cj/scripts --epochs 200 --noise-std 1.0
bash _run.sh pretrain --mt-dir ../bot4cj/scripts --epochs 200 --noise-std 1.0 \
--resume checkpoints/pretrain_last.pt
# 用预训练权重作为微调起点
# 注意:预训练在非归一化动作空间进行,微调时请勿开启 --normalize-actions,
# 否则输入分布与预训练权重不匹配,预训练收益会大打折扣
# 用预训练权重作为微调起点(pretrain 也支持 --init-from,行为同 train)
bash _run.sh train --data-dir ./data --epochs 50 \
--init-from checkpoints/pretrain_best_inference.pt
```
@@ -72,9 +70,21 @@ bash _run.sh train --data-dir ./data --epochs 50 \
| 参数 | 说明 |
|------|------|
| `--mt-dir` | .mt 脚本目录(如 `../bot4cj/scripts`) |
| `--noise-std` | 高斯噪声标准差(数据增强,建议 0.5~2.0) |
| `--noise-std` | 高斯噪声标准差(数据增强,建议 0.5~2.0;与 `--normalize-actions` 联用时按 z-score 单位,建议 0.05~0.2) |
| `--random-images` | 用 ImageNet 归一化均匀噪声替代全零图像占位,**恢复 BN 统计**(详见下方警告) |
| `--init-from` | 预训练 checkpoint 路径,作为微调起点(strict=False;与当前运行动作空间不一致时打印 WARNING) |
> **注意**:.mt 脚本列数(12)与 `action_dim`(18)不一致时自动补零。预训练保存到 `checkpoints/pretrain_best_inference.pt`。
> 预训练与微调必须使用**同一动作空间**(`--normalize-actions` 同时开或同时关),
> 否则输入分布与权重不匹配,预训练收益会大打折扣。
> **警告(2026-08-13 实测)**:全零图像占位会让 BN `running_var` 停在初始小值,
> 推理时特征放大到 ±1e6,**HEF uint8 量化不可行**(输出对输入不敏感)。
> 面向 NPU 部署的预训练/微调请务必使用 `--random-images`(或真实数据微调),
> 导出时 `export_deploy.py` 的 `check_token_ranges` 会拦截退化的 checkpoint。
> 详见 [根因分析报告](../docs/reports/actpred_hef_insensitivity_rootcause_20260813.md)。
> 建议同时开启 `--normalize-actions`:z-score 后 act_tokens 与 img_tokens 同量级,
> 避免 HEF merge 点共享量化 scale 粗量化图像路径。
## 数据格式
+18 -1
View File
@@ -56,6 +56,20 @@ model.load_state_dict(ck["model_state_dict"])
model.eval()
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d)
# 归一化模型:torch 参考在喂入前 z-score;HailoPredictor 传 stats_path
# 由封装内部归一化(hist_real/hist_zero 保持原始值,避免双重归一化)
import os
stats_path = None
if cfg.data.normalize_actions:
stats_path = cfg.data.action_stats_path or os.path.join(
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
if not os.path.isabs(stats_path):
stats_path = os.path.join(os.path.dirname(os.path.abspath(CKPT)),
stats_path)
if not os.path.exists(stats_path):
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
print(f"HailoPredictor stats: {stats_path}")
from torchvision import transforms
tf = transforms.Compose([
transforms.Resize((cfg.data.image_height, cfg.data.image_width)),
@@ -67,6 +81,9 @@ tf = transforms.Compose([
def torch_pred(imgs, hist, use_learned_pred=True):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
h = torch.tensor(hist).unsqueeze(0)
if stats_path is not None:
stats = np.load(stats_path)
h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1])
if use_learned_pred:
with torch.no_grad():
return model(x, h).squeeze(0).numpy()
@@ -78,7 +95,7 @@ def torch_pred(imgs, hist, use_learned_pred=True):
return out
with HailoPredictor(HEF_PATH) as p:
with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} "
f"history={p.history_len} action_dim={p.action_dim}")
t0 = time.perf_counter()
+18
View File
@@ -24,6 +24,20 @@ model = ActionPredictor(cfg.model, cfg.data, pretrained=False)
model.load_state_dict(ck["model_state_dict"])
model.eval()
action_stats = None
if cfg.data.normalize_actions:
import os
stats_path = cfg.data.action_stats_path or os.path.join(
os.path.dirname(os.path.abspath(CKPT)), "action_stats.npy")
if not os.path.isabs(stats_path):
stats_path = os.path.join(os.path.dirname(os.path.abspath(CKPT)),
stats_path)
action_stats = np.load(stats_path)
if action_stats.shape != (2, cfg.data.action_dim):
raise ValueError(f"action stats 形状应为 (2, {cfg.data.action_dim}),"
f"实际为 {action_stats.shape}: {stats_path}")
print(f"history 将 z-score 归一化({stats_path})")
sess = ort.InferenceSession(ONNX)
in_names = [i.name for i in sess.get_inputs()]
print("ONNX inputs:", in_names)
@@ -48,6 +62,10 @@ hist_real = np.array([
], dtype=np.float32)
hist_zero = np.zeros((5, 18), dtype=np.float32)
if action_stats is not None:
hist_real = (hist_real - action_stats[0]) / action_stats[1]
hist_zero = (hist_zero - action_stats[0]) / action_stats[1]
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,128)
pred_none = np.zeros((1, 1, 1, 128), dtype=np.float32)
+76
View File
@@ -12,13 +12,21 @@ Reshape 序列。此脚本逐项改造(权重数值完全不变):
"""
import argparse
import os
import numpy as np
import torch
import torch.nn as nn
from config import parse_checkpoint_config
from model import ActionPredictor
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
MAX_TOKEN_ABS = 100.0
MERGE_RATIO_WARN = 32.0
class ManualAttnBlock(nn.Module):
"""等价替换 nn.TransformerEncoderLayer(d=128, nhead=4, ff=256, norm_first=True)"""
@@ -124,6 +132,57 @@ class DeployWrapper(nn.Module):
return self.head(tokens[:, :, -1:, :]).squeeze(1) # (1, 1, action_dim)
def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
width: int, history_len: int, action_dim: int,
pred_token: torch.Tensor, max_abs: float = MAX_TOKEN_ABS,
action_stats_path: str = None):
"""导出前检查 token 量级:幅度过大时 uint8 量化会把小信号压成 0
预训练用全零图像占位 → BN running_var 停在初始小值 → 推理时每级
BN 放大 ~50 倍,图像 token 可达 ±2e5。DFC 在 token merge 点按最大
幅度分配共享量化 scale(实测 scale=1965.92,pred_token/act_tokens
全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。
归一化模型(normalize_actions)的 HEF 输入是 z-score 后的历史,
检查输入须与推理端一致。
"""
rng = np.random.default_rng(42)
raw = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
norm = (raw.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
frames = [torch.tensor(norm[None]) for _ in range(frame_len)]
history = rng.uniform(0.0, 500.0, size=(1, 1, history_len, action_dim))
if action_stats_path is not None:
stats = np.load(action_stats_path)
if stats.shape != (2, action_dim):
raise ValueError(
f"action stats 形状应为 (2, {action_dim}),"
f"实际为 {stats.shape}: {action_stats_path}")
# 归一化模型直接在 z 空间采样:raw 均匀采样会在零方差列上
# 产生天文数字 z 值(实际推理中这些列恒为 0,z 恒为 0)
history = rng.normal(0.0, 1.5, size=(1, 1, history_len, action_dim))
history = torch.tensor(history, dtype=torch.float32)
with torch.no_grad():
feats = [wrapper._frame_feat(f, i).permute(0, 2, 3, 1)
for i, f in enumerate(frames)]
img_tokens = torch.cat(feats, dim=2) + wrapper.proj_bias
act_tokens = torch.matmul(history, wrapper.act_w) + wrapper.act_b
img_max = img_tokens.abs().max().item()
act_max = act_tokens.abs().max().item()
pred_max = pred_token.abs().max().item()
print(f"token 量级检查: |img|={img_max:.4g} |act|={act_max:.4g} "
f"|pred|={pred_max:.4g}(图像上限 {max_abs})")
if img_max > max_abs:
raise SystemExit(
f"错误: 图像 token 幅度 {img_max:.3g} 超过可量化上限 {max_abs}。"
f"checkpoint 的 BN 统计退化(预训练用全零图像占位,running_var "
f"停在初始小值),uint8 量化会把小信号全部压成 0、HEF 输出恒定。"
f"请先用真实/随机图像数据微调后再导出。")
if act_max > MERGE_RATIO_WARN * max(img_max, 1.0):
print(f"WARNING: act_tokens 幅度 {act_max:.3g} 远大于 img_tokens "
f"{img_max:.3g},merge 点共享 scale 会粗量化图像路径;"
f"建议用 --normalize-actions 训练(动作归一化)压小 act_tokens")
def main():
parser = argparse.ArgumentParser(description="Export Hailo-friendly ONNX")
parser.add_argument("--model", required=True, help="checkpoint path")
@@ -143,6 +202,23 @@ def main():
h, w = cfg.data.image_height, cfg.data.image_width
f = cfg.data.frame_len
stats_path = None
if cfg.data.normalize_actions:
# 与 predict.py 语义一致:相对路径按 checkpoint 目录解析
stats_path = cfg.data.action_stats_path
if not stats_path:
stats_path = os.path.join(
os.path.dirname(os.path.abspath(args.model)),
"action_stats.npy")
elif not os.path.isabs(stats_path):
stats_path = os.path.join(
os.path.dirname(os.path.abspath(args.model)), stats_path)
if not os.path.exists(stats_path):
raise FileNotFoundError(
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
check_token_ranges(wrapper, f, h, w, cfg.data.history_len,
cfg.data.action_dim, model.pred_token.data.unsqueeze(1),
action_stats_path=stats_path)
dummy_frames = [torch.randn(1, h, w, 3) for _ in range(f)]
dummy_history = torch.randn(1, 1, cfg.data.history_len, cfg.data.action_dim)
dummy_pred = model.pred_token.data.unsqueeze(1).clone() # (1, 1, 1, d)
+22
View File
@@ -22,6 +22,7 @@
"""
import argparse
import os
import numpy as np
@@ -77,6 +78,27 @@ def build_deploy_set(args):
history = build_action_history_samples(
args.mt_dir, args.samples, cfg.history_len, cfg.action_dim, rng)
history = history[:, None, :, :] # (N, 1, H_len, D)
if cfg.normalize_actions:
# 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义),
# 校准数据必须走同一变换,否则量化范围与推理分布不匹配
stats_path = cfg.action_stats_path
if not stats_path:
stats_path = os.path.join(
os.path.dirname(os.path.abspath(args.model)),
"action_stats.npy")
elif not os.path.isabs(stats_path):
stats_path = os.path.join(
os.path.dirname(os.path.abspath(args.model)), stats_path)
if not os.path.exists(stats_path):
raise FileNotFoundError(
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
stats = np.load(stats_path)
if stats.shape != (2, cfg.action_dim):
raise ValueError(
f"action stats 形状应为 (2, {cfg.action_dim}),"
f"实际为 {stats.shape}: {stats_path}")
history = (history - stats[0]) / stats[1]
print(f" action_history: 已 z-score 归一化({stats_path})")
model = ActionPredictor(parse_checkpoint_config(ckpt["config"]).model,
cfg, pretrained=False)
+103 -22
View File
@@ -12,6 +12,12 @@
数据增强:
--noise-std 控制高斯噪声标准差。因 .mt 脚本数据量有限,
适度噪声(如 0.5~2.0)可有效防止过拟合。
图像占位:
--random-images 用 ImageNet 归一化的均匀噪声替代全零占位。
全零图像会让 BN running_var 停在初始小值(推理时每级放大 ~50 倍,
特征达 ±1e6),导致 HEF uint8 量化不可行;微调阶段必须用噪声或
真实图像恢复 BN 统计。
"""
import json
@@ -27,6 +33,9 @@ from config import Config, build_argparser, apply_overrides, validate_config_val
from model import ActionPredictor
from mt_parser import parse_mt_directory
_IMAGENET_MEAN = [0.485, 0.456, 0.406]
_IMAGENET_STD = [0.229, 0.224, 0.225]
class MTActionDataset(Dataset):
"""从 .mt @Action 矩阵构建的滑动窗口数据集(无图像)
@@ -35,9 +44,12 @@ class MTActionDataset(Dataset):
"""
def __init__(self, mt_dir: str, history_len: int, action_dim: int,
file_indices: list = None, files: list = None):
file_indices: list = None, files: list = None,
action_mean=None, action_std=None):
self.history_len = history_len
self.action_dim = action_dim
self._action_mean = action_mean
self._action_std = action_std
self.samples = []
# 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录)
@@ -70,32 +82,50 @@ class MTActionDataset(Dataset):
def __getitem__(self, idx):
history, target = self.samples[idx]
history = torch.from_numpy(history.copy())
target = torch.from_numpy(target.copy())
if self._action_mean is not None:
history = (history - self._action_mean) / self._action_std
target = (target - self._action_mean) / self._action_std
return {
"action_history": torch.from_numpy(history.copy()),
"target": torch.from_numpy(target.copy()),
"action_history": history,
"target": target,
}
def _placeholder_images(batch, frame_len, image_height, image_width,
device, random_images: bool):
"""构建 (B,F,3,H,W) 图像占位:全零或 ImageNet 归一化均匀噪声"""
if not random_images:
return torch.zeros(batch, frame_len, 3, image_height, image_width,
device=device)
x = torch.rand(batch, frame_len, 3, image_height, image_width,
device=device)
mean = torch.tensor(_IMAGENET_MEAN, device=device).view(1, 1, 3, 1, 1)
std = torch.tensor(_IMAGENET_STD, device=device).view(1, 1, 3, 1, 1)
return (x - mean) / std
def pretrain_one_epoch(model, loader, optimizer, device, image_width,
image_height, frame_len,
max_grad_norm=None, noise_std=0.0):
max_grad_norm=None, noise_std=0.0,
random_images: bool = False):
model.train()
total_loss = 0.0
count = 0
zero_images = None
images = 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_images is None or zero_images.size(0) != history.size(0):
zero_images = torch.zeros(
history.size(0), frame_len, 3, image_height, image_width,
device=device,
)
if images is None or images.size(0) != history.size(0):
images = _placeholder_images(
history.size(0), frame_len, image_height, image_width,
device, random_images)
optimizer.zero_grad()
pred = model(zero_images, history)
pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target)
loss.backward()
if max_grad_norm:
@@ -107,20 +137,20 @@ def pretrain_one_epoch(model, loader, optimizer, device, image_width,
@torch.no_grad()
def validate(model, loader, device, image_width, image_height, frame_len):
def validate(model, loader, device, image_width, image_height, frame_len,
random_images: bool = False):
model.eval()
total_loss = 0.0
count = 0
zero_images = None
images = None
for batch in tqdm(loader, desc=" val ", leave=False):
history = batch["action_history"].to(device)
target = batch["target"].to(device)
if zero_images is None or zero_images.size(0) != history.size(0):
zero_images = torch.zeros(
history.size(0), frame_len, 3, image_height, image_width,
device=device,
)
pred = model(zero_images, history)
if images is None or images.size(0) != history.size(0):
images = _placeholder_images(
history.size(0), frame_len, image_height, image_width,
device, random_images)
pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target)
total_loss += loss.item() * history.size(0)
count += history.size(0)
@@ -135,6 +165,9 @@ def main():
help=".mt 脚本目录(如 ../bot4cj/scripts)")
parser.add_argument("--noise-std", type=float, default=0.0,
help="高斯噪声标准差(数据增强,建议 0.5~2.0)")
parser.add_argument("--random-images", action="store_true",
help="用 ImageNet 归一化均匀噪声替代全零图像占位"
"(微调时恢复 BN 统计,避免 HEF 量化退化)")
args = parser.parse_args()
cfg = apply_overrides(Config(), args)
@@ -172,9 +205,38 @@ def main():
args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=train_file_indices, files=all_files,
)
# 动作归一化(与 train.py 语义一致):无 stats 文件时按训练集
# targets 计算并保存到 checkpoint_dir,验证集复用同一 stats
action_mean = action_std = None
if cfg.data.normalize_actions:
if cfg.data.action_stats_path:
if not os.path.exists(cfg.data.action_stats_path):
raise FileNotFoundError(
f"指定的 action_stats_path 不存在: "
f"{cfg.data.action_stats_path}")
stats = np.load(cfg.data.action_stats_path)
else:
all_actions = np.stack([s[1] for s in train_ds.samples])
stats = np.stack([all_actions.mean(axis=0),
all_actions.std(axis=0) + 1e-8])
stats_path = os.path.join(cfg.train.checkpoint_dir,
"action_stats.npy")
np.save(stats_path, stats)
cfg.data.action_stats_path = stats_path
print(f"action_stats 已保存: {stats_path}(训练集 targets 统计)")
if stats.shape != (2, cfg.data.action_dim):
raise ValueError(
f"action stats 形状应为 (2, {cfg.data.action_dim}),"
f"实际为 {stats.shape}")
action_mean, action_std = stats[0], stats[1]
train_ds._action_mean = action_mean
train_ds._action_std = action_std
val_ds = MTActionDataset(
args.mt_dir, cfg.data.history_len, cfg.data.action_dim,
file_indices=val_file_indices, files=all_files,
action_mean=action_mean, action_std=action_std,
)
print(f"Files: {len(train_file_indices)} train, {len(val_file_indices)} val")
@@ -205,7 +267,26 @@ def main():
f"backbone={cfg.model.cnn_backbone}, "
f"attn={cfg.model.attn_layers}x{cfg.model.attn_heads}h"
f"@{cfg.model.attn_dim}")
print(f"Pre-training: noise_std={args.noise_std}")
print(f"Pre-training: noise_std={args.noise_std}, "
f"random_images={args.random_images}")
if args.init_from:
init_ckpt = torch.load(args.init_from, map_location="cpu",
weights_only=True)
init_cfg = init_ckpt.get("config", {})
init_norm = init_cfg.get("data", {}).get("normalize_actions", False)
if init_norm != cfg.data.normalize_actions:
print(f"WARNING: init-from checkpoint 的 normalize_actions="
f"{init_norm} 与当前运行 "
f"{cfg.data.normalize_actions} 不一致,输入分布不匹配"
f"会损失预训练收益(见 README 预训练说明)")
missing, unexpected = model.load_state_dict(
init_ckpt["model_state_dict"], strict=False)
if missing:
print(f" init-from missing keys: {missing}")
if unexpected:
print(f" init-from unexpected keys: {unexpected}")
print(f"Loaded pretrained weights from: {args.init_from}")
optimizer = torch.optim.AdamW(
model.parameters(), lr=cfg.train.lr,
@@ -241,11 +322,11 @@ def main():
train_loss = pretrain_one_epoch(
model, train_loader, optimizer, device,
cfg.data.image_width, cfg.data.image_height, cfg.data.frame_len,
cfg.train.max_grad_norm, args.noise_std,
cfg.train.max_grad_norm, args.noise_std, args.random_images,
)
val_loss = validate(model, val_loader, device,
cfg.data.image_width, cfg.data.image_height,
cfg.data.frame_len)
cfg.data.frame_len, args.random_images)
scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")