diff --git a/actpred/README.md b/actpred/README.md index 3ddf3ad..c89afd2 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -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 粗量化图像路径。 ## 数据格式 diff --git a/actpred/_npu_test.py b/actpred/_npu_test.py index b000b85..ef719b3 100644 --- a/actpred/_npu_test.py +++ b/actpred/_npu_test.py @@ -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() diff --git a/actpred/_onnx_validate.py b/actpred/_onnx_validate.py index 6bb8881..e24896f 100644 --- a/actpred/_onnx_validate.py +++ b/actpred/_onnx_validate.py @@ -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) diff --git a/actpred/export_deploy.py b/actpred/export_deploy.py index 3b3268e..0bcb08c 100644 --- a/actpred/export_deploy.py +++ b/actpred/export_deploy.py @@ -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) diff --git a/actpred/gen_calibration.py b/actpred/gen_calibration.py index decf65c..8ab12f0 100644 --- a/actpred/gen_calibration.py +++ b/actpred/gen_calibration.py @@ -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) diff --git a/actpred/pretrain_mt.py b/actpred/pretrain_mt.py index 1948392..08446a0 100644 --- a/actpred/pretrain_mt.py +++ b/actpred/pretrain_mt.py @@ -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}") diff --git a/docs/reports/actpred_hailo_hat2_inference_research_20260813.md b/docs/reports/actpred_hailo_hat2_inference_research_20260813.md index 0906c2a..4639b32 100644 --- a/docs/reports/actpred_hailo_hat2_inference_research_20260813.md +++ b/docs/reports/actpred_hailo_hat2_inference_research_20260813.md @@ -146,6 +146,11 @@ resnet HEF 同 API 验证输入通道有效,排除封装问题)。 3. 若仍不敏感:检查 `_optimized.har` 输入层下游连通性,定位 optimize 断连的具体算子。 +> **更新(同日)**:上述重编译复验已完成,输出仍不敏感;根因已定位 +> 为预训练 checkpoint 的 BN 统计退化导致图像特征 ±1e6、uint8 量化 +> 压死全部信号(非编译侧断连)。详见 +> [根因分析报告](actpred_hef_insensitivity_rootcause_20260813.md)。 + ## 6. 结论与产出 1. AI HAT 2 软件栈完整可用,新 API 推理链路实测打通(resnet50 307 FPS)。 diff --git a/docs/reports/actpred_hef_insensitivity_rootcause_20260813.md b/docs/reports/actpred_hef_insensitivity_rootcause_20260813.md new file mode 100644 index 0000000..1128240 --- /dev/null +++ b/docs/reports/actpred_hef_insensitivity_rootcause_20260813.md @@ -0,0 +1,138 @@ +# actpred HEF 输出不敏感问题根因分析报告 + +日期:2026-08-13 +设备:Raspberry Pi 5 + AI HAT 2(Hailo-10H);编译端 x86 + Hailo DFC 5.3 +前情:[actpred_hailo_hat2_inference_research_20260813.md](actpred_hailo_hat2_inference_research_20260813.md) §5 + +## 1. 背景 + +首版 HEF 在 NPU 上输出对全部 5 个输入不敏感(全 0 / 全 255 / 真实数据 +输出完全一致)。修复连通性检查后 x86 重编译,新 HEF(224×224,含 +`_optimized.har` 连通性 PASS)拷回树莓派复验,**输出仍对输入不敏感**。 +本报告记录复验实验与最终根因定位。 + +## 2. 复验实验 + +### 2.1 新 HEF 扩展敏感性测试(`_npu_test.py` + 变体) + +对 6 种输入变体逐组测试,与基线输出对比(`max_diff`): + +| 变体 | max_diff | 结论 | +|---|---|---| +| 全零帧 | 0.0000 | 帧路径无效 | +| 全 255 帧 | 0.0000 | 帧路径无效(极端值同样无效) | +| 全零历史 | 0.0000 | 历史路径无效 | +| 全 500 历史 | 0.0000 | 历史路径无效 | +| pred_token=0 | 0.0000 | pred_token 无效 | +| pred_token=±0.09 | 0.0000 | pred_token 无效 | + +输出为恒定向量 `[11.814, -1.575, 9.451, 4.725, 11.814, 3.15, ...]` +(uint8 q=`[17,0,14,8,17,6]`,zp=2,scale=0.7876)。 + +### 2.2 新 HEF vstream 量化参数 + +| 输入 | 形状 | zp | scale | 判定 | +|---|---|---|---|---| +| input_layer1~3(帧) | (224,224,3) | 114 | 0.0187 | 正常(≈归一化像素范围/255) | +| input_layer4(历史) | (1,5,18) | 0 | 1.961 | 正常(=500/255,与数据范围 0~500 一致) | +| input_layer5(pred_token) | (1,1,128) | 129 | **1965.92** | 退化(数据 ±0.09,合理 scale 应 ~0.0007) | + +与 x86 备份 tgz 中旧 HEF(64×64)对比:pred_token scale +`18571.7 → 1965.92`。**scale 随校准数据变化,说明抖动校准确实生效**—— +推翻了"抖动校准未生效"的初始假设。 + +### 2.3 控制组(排除环境因素) + +resnet_v1_18.hef 同 API、同驱动实测:真实图 top1=984 vs 全零图 +top1=111,输出随输入变化。**硬件、驱动 5.3.0、hailo_platform 封装 +全部正常,问题在 HEF 本身。** + +## 3. 根因:BN 统计退化 → 特征量级 ±1e6 → 量化压死全部信号 + +### 3.1 证据链 + +1. `pretrain_best_inference.pt` 中全部 4 个 BatchNorm 的 `running_var` + 均为 `3.7e-4`(每通道逐位相同)。预训练用全零图像占位,BN 从未 + 见到真实数据,running_var 经 ~75 次零方差更新指数衰减至此。 +2. 推理(eval 模式)下每级 BN 放大 `1/sqrt(3.7e-4 + 1e-5) ≈ 51` 倍, + 4 级串联放大 ~7e6 倍。实测(torch 复算部署版图): + - CNN 末级输出:**±1.4e6 ~ 2.5e6**(真实帧 / 全零帧均是) + - `img_tokens`:±2.2e5 ~ 9.6e5 + - `act_tokens`:±537 ~ 694(历史 0~500 × act_proj 权重,量级正常) +3. torch 浮点推理为何不受影响:pre-LN 架构的 LayerNorm 在注意力前 + 归一化 token 幅度,±1e6 的残差流在 float32 下不损失精度。但 uint8 + 量化无法表达这种动态范围。 + +### 3.2 失效机理(量化视角) + +DFC 在 token merge 点(`concat(img, act, pred) + pos_embed` 的 +ew_add)按**最大幅度分配共享量化 scale**: + +``` +merge scale ≈ 250k / 127.5 ≈ 1965.92 ← 与 HEF 边缘实测一致 +``` + +该 scale 下各支路命运: + +| 支路 | 幅度 | 量化结果 | +|---|---|---| +| img_tokens | ±235k | 勉强存活(~120 级),但中间激活 ±1e6 超出 uint8 校准范围 → 饱和,任何输入差异被抹平 | +| act_tokens | ±537 | 537/1965.92 ≈ 0.27 → 全部量化到 zp(**信号归零**) | +| pred_token | ±0.09 | 全部量化到 zp(**信号归零**) | +| pos_embed | ±0.06 | 全部量化到 zp(**信号归零**) | + +图像路径经 CNN 内各级退化激活的饱和 + 其余支路归零,最终网络退化为 +对任何输入输出同一常量——与 2.1 的实测完全吻合。 + +### 3.3 为什么连通性检查通过仍然失败 + +`dfc_compile.py` 的检查验证的是**图拓扑**(输入层 BFS 可达输出层), +拓扑确实连通。问题是**数值量级**:量化把信号压成 0,等价于数值上的 +断连。两者不矛盾,但都必须在编译前拦截。 + +## 4. 结论 + +- 当前 pretrain checkpoint 的图像支路数学上**无法 uint8 量化** + (特征 ±1e6 远超表达范围),无论重编译多少次都不可用。 +- 必须先用真实/随机图像数据微调,恢复 BN 统计(特征回到 O(1)), + 再重走导出→编译链路。 +- pred_token 抖动校准本身没有 bug:merge 点 scale 由 ±235k 的图像 + token 主导,pred_token 无论怎么抖都会被压死。 + +## 5. 修复与后续 + +### 5.1 已落地:导出前量级前置检查 + +`actpred/export_deploy.py` 新增 `check_token_ranges()`:导出前用随机 +噪声帧 + 0~500 均匀历史实测 token 量级,`|img| > 100` 直接中止导出 +并提示微调;`act/img` 比例 > 32 时警告 merge 会粗量化图像路径。 + +实测:退化 checkpoint `|img|=2.46e5` → 正确拦截;模拟健康 BN +(var=1)`|img|=0.117` → 放行。全量单测 76/76 通过。 + +### 5.2 建议路径 + +1. x86 微调:随机图像 + 混入真实帧(本机 6 episode × 30 帧可拷贝 + 过去),动作目标继续用 .mt 动力学;微调后 BN 统计恢复。 +2. 重走链路:`export_deploy.py`(自带量级检查)→ `gen_calibration.py + --deploy` → `dfc_compile.py`(连通性检查)→ 拷回 HEF。 +3. `_npu_test.py` 4 角敏感性复验作为验收门槛。 + +### 5.3 遗留风险 + +- 健康场景下 `act_tokens`(±700)仍远大于 `img_tokens`(O(1)): + merge 共享 scale 会把图像路径粗量化到 ~2 级。后续可在部署图把 + `action_history` 输入除以 255 并将 `act_w` 放大 255 倍(线性等价, + 数学不变),把 act_tokens 压到 O(1) 量级。 +- 随机图像微调后 BN 统计匹配噪声分布,部署端真实摄像头帧的统计 + 略有差异,特征量级可能偏大——导出时的新检查会兜底拦截,混入 + 真实帧可进一步降低风险。 + +### 5.4 验证链(防再犯) + +| 阶段 | 检查 | 脚本 | +|---|---|---| +| 导出时 | token 量级 | `export_deploy.py` `check_token_ranges` | +| 编译前 | ONNX 数值正确性 | `_onnx_validate.py` | +| 编译时 | 输入连通性 | `dfc_compile.py` `check_input_connectivity` | +| 部署后 | 4 角输入敏感性 | `_npu_test.py` |