feat(actpred): log_range 归一化——spk 两列对数尺度修正精度错配
fixed_range 线性 min-max 下常用值挤在低端(10Hz→0.002),闭环反馈 中小绝对误差=大相对误差(模型 C/D 发散根因)。新增 normalize_mode= log_range:spk.F/spk.delay 用 log10(1+x) 对数尺度(10Hz→0.27), 舵机列保持线性。全链路接入(dataset/pretrain/predict/onnx/hailo/ calibration/export),与 v3 审计 none 模式/元数据自动识别合并。
This commit is contained in:
1 parent
fc310c9081
commit
1cf428f384
11 files changed
+306
-66
No files matched your search
+37
-5
@@ -25,6 +25,17 @@ ACTION_HI = np.array(
|
||||
[1.0] * 6, # 12-17 保留位(恒 0,span=1.0 保持归一化后为 0)
|
||||
dtype=np.float32)
|
||||
|
||||
# 归一化方式:fixed_range 线性 min-max;log_range 在 spk 两列上用
|
||||
# log10(1+x) 对数尺度,修正低端聚集导致的精度错配
|
||||
# (10Hz 在线性尺度占 0.002、对数尺度占 0.27)
|
||||
RANGE_MODES = ("fixed_range", "log_range")
|
||||
LOG_COLS = (10, 11) # spk.F / spk.delay
|
||||
|
||||
|
||||
def is_range_mode(mode: str) -> bool:
|
||||
"""normalize_mode 是否为固定范围类(fixed_range / log_range)"""
|
||||
return mode in RANGE_MODES
|
||||
|
||||
|
||||
def action_bounds(action_dim: int = 18):
|
||||
"""(lo, hi) 物理界:action_dim ≤ 18 取前缀;> 18 的超出列按 [0, 1] 补足"""
|
||||
@@ -35,21 +46,42 @@ def action_bounds(action_dim: int = 18):
|
||||
np.concatenate([ACTION_HI, np.ones(pad, dtype=np.float32)]))
|
||||
|
||||
|
||||
def normalize_action(action, action_dim: int = None) -> np.ndarray:
|
||||
def normalize_action(action, action_dim: int = None,
|
||||
mode: str = "fixed_range") -> np.ndarray:
|
||||
"""原始动作向量 → [0,1] 归一化向量(clamp 防御越界值)
|
||||
|
||||
action: (..., action_dim) 任意前导维;action_dim 缺省取末维长度。
|
||||
mode="fixed_range":全列线性 min-max;
|
||||
mode="log_range":LOG_COLS 列用 log10(1+x)(负值先截 0),其余列线性。
|
||||
"""
|
||||
a = np.asarray(action, dtype=np.float32)
|
||||
lo, hi = action_bounds(action_dim or a.shape[-1])
|
||||
return np.clip((a - lo) / (hi - lo), 0.0, 1.0)
|
||||
out = np.clip((a - lo) / (hi - lo), 0.0, 1.0)
|
||||
if mode == "log_range":
|
||||
for c in LOG_COLS:
|
||||
if c < a.shape[-1]:
|
||||
hc = float(hi[c])
|
||||
col = np.clip(a[..., c], 0.0, None)
|
||||
out[..., c] = np.log10(col + 1.0) / np.log10(hc + 1.0)
|
||||
return out
|
||||
|
||||
|
||||
def denormalize_action(norm_action, action_dim: int = None) -> np.ndarray:
|
||||
"""[0,1] 归一化向量 → 原始动作向量(不 clamp,保留模型外推能力)"""
|
||||
def denormalize_action(norm_action, action_dim: int = None,
|
||||
mode: str = "fixed_range") -> np.ndarray:
|
||||
"""[0,1] 归一化向量 → 原始动作向量(不 clamp,保留模型外推能力)
|
||||
|
||||
mode="log_range" 时 LOG_COLS 列指数还原:x = 10^(n·log10(hi+1)) - 1。
|
||||
"""
|
||||
n = np.asarray(norm_action, dtype=np.float32)
|
||||
lo, hi = action_bounds(action_dim or n.shape[-1])
|
||||
return n * (hi - lo) + lo
|
||||
out = n * (hi - lo) + lo
|
||||
if mode == "log_range":
|
||||
for c in LOG_COLS:
|
||||
if c < n.shape[-1]:
|
||||
hc = float(hi[c])
|
||||
out[..., c] = (np.power(10.0, n[..., c] * np.log10(hc + 1.0))
|
||||
- 1.0)
|
||||
return out
|
||||
|
||||
|
||||
def default_column_weights(action_dim: int = 18) -> np.ndarray:
|
||||
|
||||
+7
-4
@@ -17,6 +17,8 @@ class DataConfig:
|
||||
# "zscore" 数据统计 z-score(现状,依赖 action_stats.npy)
|
||||
# "fixed_range" 固定物理范围 min-max → [0,1](免 stats 文件,零状态部署,
|
||||
# 常量表见 action_norm.py;越界值 clamp 防御)
|
||||
# "log_range" 同上,但 spk.F/spk.delay 两列用 log10(1+x) 对数尺度,
|
||||
# 修正低端聚集导致的精度错配(见实验报告)
|
||||
normalize_mode: str = "zscore"
|
||||
# 逐列损失权重(仅训练损失用,推理端无感知):None → action_norm
|
||||
# .default_column_weights(action_dim)(舵机 1.0 / spk 0.2 / 保留位 0.0)。
|
||||
@@ -94,9 +96,10 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
|
||||
parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None)
|
||||
parser.add_argument("--action-stats-path", type=str, default=None)
|
||||
parser.add_argument("--normalize-mode", type=str, default=None,
|
||||
choices=["zscore", "fixed_range"],
|
||||
choices=["zscore", "fixed_range", "log_range"],
|
||||
help="动作归一化方式:zscore=数据统计(依赖 action_stats.npy);"
|
||||
"fixed_range=固定物理范围 min-max→[0,1](免 stats 文件)")
|
||||
"fixed_range=固定物理范围 min-max→[0,1](免 stats 文件);"
|
||||
"log_range=spk 两列对数尺度(免 stats 文件)")
|
||||
parser.add_argument("--column-weights", type=str, default=None,
|
||||
help="逐列损失权重(逗号分隔 action_dim 个浮点),"
|
||||
"缺省用默认表(舵机1.0/spk0.2/保留位0.0);仅影响训练损失")
|
||||
@@ -172,10 +175,10 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig,
|
||||
f"attn_dim={model_cfg.attn_dim} 必须能被 attn_heads="
|
||||
f"{model_cfg.attn_heads} 整除")
|
||||
|
||||
if data_cfg.normalize_mode not in ("zscore", "fixed_range"):
|
||||
if not (data_cfg.normalize_mode in ("zscore", "fixed_range", "log_range")):
|
||||
raise ValueError(
|
||||
f"normalize_mode={data_cfg.normalize_mode!r} 非法,"
|
||||
f"应为 'zscore' 或 'fixed_range'")
|
||||
f"应为 'zscore'、'fixed_range' 或 'log_range'")
|
||||
|
||||
if data_cfg.column_weights is not None:
|
||||
w = data_cfg.column_weights
|
||||
|
||||
+10
-7
@@ -11,7 +11,7 @@ from torchvision import transforms
|
||||
from torchvision.transforms import functional as TF
|
||||
|
||||
from imu_norm import imu_scales
|
||||
from action_norm import normalize_action
|
||||
from action_norm import normalize_action, is_range_mode
|
||||
|
||||
|
||||
def _natural_key(p):
|
||||
@@ -29,8 +29,11 @@ class ActionDataset(Dataset):
|
||||
imu_dim: int = 0, normalize_mode: str = "zscore"):
|
||||
self.history_len = history_len
|
||||
self.action_dim = action_dim
|
||||
# 固定物理范围归一化(免 stats 文件);仅 normalize_actions=True 生效
|
||||
self._fixed_range = normalize_actions and normalize_mode == "fixed_range"
|
||||
# 固定范围类归一化(fixed_range / log_range,免 stats 文件);
|
||||
# 仅 normalize_actions=True 生效
|
||||
self._range_mode = (normalize_mode
|
||||
if (normalize_actions and is_range_mode(normalize_mode))
|
||||
else None)
|
||||
# R2 方案一:>0 时样本带 "imu" 键(归一化姿态向量,缺失 episode 补零)
|
||||
self.imu_dim = imu_dim
|
||||
self.image_width = image_width
|
||||
@@ -87,7 +90,7 @@ class ActionDataset(Dataset):
|
||||
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
if normalize_actions and not self._fixed_range:
|
||||
if normalize_actions and not self._range_mode:
|
||||
if action_stats_path:
|
||||
if not os.path.exists(action_stats_path):
|
||||
raise FileNotFoundError(
|
||||
@@ -119,11 +122,11 @@ class ActionDataset(Dataset):
|
||||
def _norm(self, a):
|
||||
"""动作归一化(numpy → torch 前在 numpy 空间做,保持 float32)
|
||||
|
||||
fixed_range:固定物理范围 min-max → [0,1](clamp 防御,免 stats);
|
||||
fixed_range / log_range:固定物理范围 → [0,1](clamp 防御,免 stats);
|
||||
否则 z-score(_action_mean 为 None 时直通原始值)。
|
||||
"""
|
||||
if self._fixed_range:
|
||||
return normalize_action(a, self.action_dim)
|
||||
if self._range_mode:
|
||||
return normalize_action(a, self.action_dim, mode=self._range_mode)
|
||||
if self._action_mean is None:
|
||||
return a
|
||||
return (a - self._action_mean) / self._action_std
|
||||
|
||||
@@ -173,15 +173,17 @@ def check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
|
||||
全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。
|
||||
|
||||
归一化模型(normalize_actions)的 HEF 输入是归一化后的历史,
|
||||
检查输入须与推理端一致(zscore → z 空间采样;fixed_range → [0,1])。
|
||||
检查输入须与推理端一致(zscore → z 空间采样;fixed_range /
|
||||
log_range → [0,1])。
|
||||
"""
|
||||
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 normalize_mode == "fixed_range":
|
||||
# fixed_range 归一化空间即 [0,1],直接在归一化空间采样
|
||||
from action_norm import is_range_mode
|
||||
if is_range_mode(normalize_mode):
|
||||
# fixed_range / log_range 归一化空间即 [0,1],直接在归一化空间采样
|
||||
history = rng.uniform(0.0, 1.0, size=(1, 1, history_len, action_dim))
|
||||
elif action_stats_path is not None:
|
||||
stats = np.load(action_stats_path)
|
||||
@@ -235,9 +237,10 @@ 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 and cfg.data.normalize_mode != "fixed_range":
|
||||
from action_norm import is_range_mode
|
||||
if cfg.data.normalize_actions and not is_range_mode(cfg.data.normalize_mode):
|
||||
# 与 predict.py 语义一致:相对路径按 checkpoint 目录解析
|
||||
# (fixed_range 模式免 stats 文件,跳过)
|
||||
# (fixed_range / log_range 模式免 stats 文件,跳过)
|
||||
stats_path = cfg.data.action_stats_path
|
||||
if not stats_path:
|
||||
stats_path = os.path.join(
|
||||
|
||||
@@ -101,11 +101,13 @@ 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 and cfg.normalize_mode == "fixed_range":
|
||||
# 固定物理范围归一化:校准数据与推理端同一变换(免 stats 文件)
|
||||
from action_norm import normalize_action
|
||||
history = normalize_action(history, cfg.action_dim)
|
||||
print(" action_history: 已 fixed_range 归一化(固定物理范围)")
|
||||
if cfg.normalize_actions and is_range_mode(cfg.normalize_mode):
|
||||
# 固定范围类归一化:校准数据与推理端同一变换(免 stats 文件)
|
||||
from action_norm import normalize_action, is_range_mode
|
||||
history = normalize_action(history, cfg.action_dim,
|
||||
mode=cfg.normalize_mode)
|
||||
print(f" action_history: 已 {cfg.normalize_mode} 归一化"
|
||||
f"(固定物理范围)")
|
||||
elif cfg.normalize_actions:
|
||||
# z-score 归一化模型的 HEF 输入是 z-score 历史(hailo_infer --stats 同语义),
|
||||
# 校准数据必须走同一变换,否则量化范围与推理分布不匹配
|
||||
|
||||
+13
-9
@@ -36,7 +36,8 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from imu_norm import normalize_imu
|
||||
from action_norm import normalize_action, denormalize_action
|
||||
from action_norm import (normalize_action, denormalize_action,
|
||||
is_range_mode)
|
||||
from utils import parse_action_history
|
||||
|
||||
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
@@ -69,13 +70,14 @@ class HailoPredictor:
|
||||
pred_token: np.ndarray 或 .npy 路径;export_deploy.py --pred-token-out
|
||||
产物。缺省时喂零(仅适合 pred_token 恰好全零的模型)。
|
||||
normalize_mode: 与训练时 DataConfig.normalize_mode 一致。
|
||||
"zscore"(默认)需传 stats_path;"fixed_range" 免 stats 文件,
|
||||
按固定物理范围 min-max 归一化(action_norm.py)。
|
||||
"zscore"(默认)需传 stats_path;"fixed_range" / "log_range"
|
||||
免 stats 文件,按固定物理范围归一化(action_norm.py)。
|
||||
"""
|
||||
from hailo_platform import HEF, VDevice
|
||||
|
||||
self._predict_delta = predict_delta
|
||||
self._fixed_range = normalize_mode == "fixed_range"
|
||||
self._range_mode = (normalize_mode
|
||||
if is_range_mode(normalize_mode) else None)
|
||||
self._default_pred_token = None
|
||||
if pred_token is not None:
|
||||
if isinstance(pred_token, str):
|
||||
@@ -105,7 +107,7 @@ class HailoPredictor:
|
||||
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
if stats_path is not None and not self._fixed_range:
|
||||
if stats_path is not None and not self._range_mode:
|
||||
if not os.path.exists(stats_path):
|
||||
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
|
||||
stats = np.load(stats_path)
|
||||
@@ -247,8 +249,9 @@ class HailoPredictor:
|
||||
raise ValueError(
|
||||
f"action_history 形状应为 {expected},实际为 {action_history.shape}")
|
||||
|
||||
if self._fixed_range:
|
||||
action_history = normalize_action(action_history, self.action_dim)
|
||||
if self._range_mode:
|
||||
action_history = normalize_action(action_history, self.action_dim,
|
||||
mode=self._range_mode)
|
||||
elif self._action_mean is not None:
|
||||
action_history = (action_history - self._action_mean) / self._action_std
|
||||
|
||||
@@ -310,8 +313,9 @@ class HailoPredictor:
|
||||
if self._predict_delta:
|
||||
# 残差模式:Δ̂ + a_{t-1}(归一化空间),与 predict.py 对齐
|
||||
result = result + action_history[-1]
|
||||
if self._fixed_range:
|
||||
result = denormalize_action(result, self.action_dim)
|
||||
if self._range_mode:
|
||||
result = denormalize_action(result, self.action_dim,
|
||||
mode=self._range_mode)
|
||||
elif self._action_mean is not None:
|
||||
result = result * self._action_std + self._action_mean
|
||||
if not np.all(np.isfinite(result)):
|
||||
|
||||
+16
-10
@@ -18,7 +18,8 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from imu_norm import normalize_imu
|
||||
from action_norm import normalize_action, denormalize_action
|
||||
from action_norm import (normalize_action, denormalize_action,
|
||||
is_range_mode)
|
||||
|
||||
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)
|
||||
@@ -75,10 +76,12 @@ class OnnxPredictor:
|
||||
meta = self._read_metadata()
|
||||
if normalize_mode is None:
|
||||
normalize_mode = meta.get("normalize_mode") or "zscore"
|
||||
if normalize_mode not in ("zscore", "fixed_range", "none"):
|
||||
if normalize_mode not in ("zscore", "fixed_range", "none",
|
||||
"log_range"):
|
||||
raise ValueError(f"normalize_mode={normalize_mode!r} 非法,"
|
||||
"应为 zscore/fixed_range/none")
|
||||
self._fixed_range = normalize_mode == "fixed_range"
|
||||
"应为 zscore/fixed_range/none/log_range")
|
||||
self._range_mode = (normalize_mode
|
||||
if is_range_mode(normalize_mode) else None)
|
||||
self._no_norm = normalize_mode == "none"
|
||||
self.normalize_mode = normalize_mode # 解析后的最终模式(供调用方判断)
|
||||
# R1-C 残差预测:元数据 predict_delta=1 时模型输出 Δ̂,
|
||||
@@ -88,7 +91,7 @@ class OnnxPredictor:
|
||||
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
if stats_path is not None and not (self._fixed_range or self._no_norm):
|
||||
if stats_path is not None and not (self._range_mode or self._no_norm):
|
||||
if not os.path.exists(stats_path):
|
||||
raise FileNotFoundError(f"统计文件不存在: {stats_path}")
|
||||
stats = np.load(stats_path)
|
||||
@@ -158,8 +161,9 @@ class OnnxPredictor:
|
||||
raise ValueError(
|
||||
f"action_history 形状应为 {expected},实际为 {action_history.shape}")
|
||||
|
||||
if self._fixed_range:
|
||||
action_history = normalize_action(action_history, self.action_dim)
|
||||
if self._range_mode:
|
||||
action_history = normalize_action(action_history, self.action_dim,
|
||||
mode=self._range_mode)
|
||||
elif self._action_mean is not None:
|
||||
action_history = (action_history - self._action_mean) / self._action_std
|
||||
|
||||
@@ -175,8 +179,9 @@ class OnnxPredictor:
|
||||
if self._predict_delta:
|
||||
# 残差:Δ̂ + a_{t-1}(归一化空间,与 predict.py 对齐)
|
||||
result = result + action_history[-1]
|
||||
if self._fixed_range:
|
||||
result = denormalize_action(result, self.action_dim)
|
||||
if self._range_mode:
|
||||
result = denormalize_action(result, self.action_dim,
|
||||
mode=self._range_mode)
|
||||
elif self._action_mean is not None:
|
||||
result = result * self._action_std + self._action_mean
|
||||
return result
|
||||
@@ -200,7 +205,8 @@ def main():
|
||||
help="冒号分隔各步,每步 action_dim 个值")
|
||||
parser.add_argument("--stats", default=None,
|
||||
help="action_stats.npy 路径(zscore 归一化训练才需要)")
|
||||
parser.add_argument("--normalize-mode", choices=["zscore", "fixed_range", "none"],
|
||||
parser.add_argument("--normalize-mode",
|
||||
choices=["zscore", "fixed_range", "none", "log_range"],
|
||||
default=None,
|
||||
help="与训练时 normalize_mode 一致;缺省从 ONNX 元数据"
|
||||
"自动识别(旧模型回退 zscore)")
|
||||
|
||||
+16
-10
@@ -8,7 +8,8 @@ from torchvision import transforms
|
||||
|
||||
from config import parse_checkpoint_config
|
||||
from imu_norm import normalize_imu
|
||||
from action_norm import normalize_action, denormalize_action
|
||||
from action_norm import (normalize_action, denormalize_action,
|
||||
is_range_mode)
|
||||
from model import ActionPredictor
|
||||
from utils import parse_action_history
|
||||
|
||||
@@ -50,11 +51,14 @@ class ActionPredictorInference:
|
||||
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
# fixed_range:固定物理范围 min-max(免 stats 文件);旧 checkpoint 无
|
||||
# normalize_mode 键,parse_checkpoint_config 默认 "zscore",行为不变
|
||||
self._fixed_range = (self._data_cfg.normalize_actions
|
||||
and self._data_cfg.normalize_mode == "fixed_range")
|
||||
if self._data_cfg.normalize_actions and not self._fixed_range:
|
||||
# fixed_range / log_range:固定物理范围 → [0,1](免 stats 文件);
|
||||
# 旧 checkpoint 无 normalize_mode 键,parse_checkpoint_config
|
||||
# 默认 "zscore",行为不变
|
||||
self._range_mode = (self._data_cfg.normalize_mode
|
||||
if (self._data_cfg.normalize_actions
|
||||
and is_range_mode(self._data_cfg.normalize_mode))
|
||||
else None)
|
||||
if self._data_cfg.normalize_actions and not self._range_mode:
|
||||
# 优先使用 checkpoint config 记录的 stats 路径(相对路径按 checkpoint 目录解析),
|
||||
# 旧 checkpoint 无该字段时回退到 checkpoint 同目录的 action_stats.npy
|
||||
stats_path = self._data_cfg.action_stats_path
|
||||
@@ -100,9 +104,10 @@ class ActionPredictorInference:
|
||||
frames.append(self._transform(img))
|
||||
img_tensor = torch.stack(frames).unsqueeze(0).to(self._device) # (1, F, 3, S, S)
|
||||
history = torch.tensor(action_history, dtype=torch.float32)
|
||||
if self._fixed_range:
|
||||
if self._range_mode:
|
||||
history = torch.from_numpy(
|
||||
normalize_action(action_history, self.action_dim))
|
||||
normalize_action(action_history, self.action_dim,
|
||||
mode=self._range_mode))
|
||||
elif self._action_mean is not None:
|
||||
history = (history - self._action_mean) / self._action_std
|
||||
history = history.unsqueeze(0).to(self._device)
|
||||
@@ -115,8 +120,9 @@ class ActionPredictorInference:
|
||||
if self._predict_delta:
|
||||
# 残差:Δ̂ 加回 a_{t-1}(history 末步,同为归一化空间)→ 绝对动作
|
||||
pred = history[0, -1] + pred
|
||||
if self._fixed_range:
|
||||
result = denormalize_action(pred.cpu().numpy(), self.action_dim)
|
||||
if self._range_mode:
|
||||
result = denormalize_action(pred.cpu().numpy(), self.action_dim,
|
||||
mode=self._range_mode)
|
||||
else:
|
||||
if self._action_mean is not None:
|
||||
pred = pred * self._action_std + self._action_mean
|
||||
|
||||
+17
-10
@@ -33,7 +33,8 @@ from config import (Config, build_argparser, apply_overrides,
|
||||
validate_config_values, warn_init_from_mismatch)
|
||||
from model import ActionPredictor
|
||||
from mt_parser import parse_mt_directory
|
||||
from action_norm import normalize_action, default_column_weights
|
||||
from action_norm import (normalize_action, default_column_weights,
|
||||
is_range_mode)
|
||||
from train import _weighted_mse
|
||||
|
||||
_IMAGENET_MEAN = [0.485, 0.456, 0.406]
|
||||
@@ -54,9 +55,12 @@ class MTActionDataset(Dataset):
|
||||
self.action_dim = action_dim
|
||||
self._action_mean = action_mean
|
||||
self._action_std = action_std
|
||||
# 固定物理范围归一化(免统计量;action_mean 传入时优先 z-score)
|
||||
self._fixed_range = (normalize_mode == "fixed_range"
|
||||
and action_mean is None)
|
||||
# 固定范围类归一化(fixed_range / log_range,免统计量;
|
||||
# action_mean 传入时优先 z-score)
|
||||
self._range_mode = (normalize_mode
|
||||
if (is_range_mode(normalize_mode)
|
||||
and action_mean is None)
|
||||
else None)
|
||||
self.samples = []
|
||||
|
||||
# 复用调用方已解析的结果(避免 train/val 各自重复解析同一目录)
|
||||
@@ -89,9 +93,11 @@ class MTActionDataset(Dataset):
|
||||
|
||||
def __getitem__(self, idx):
|
||||
history, target = self.samples[idx]
|
||||
if self._fixed_range:
|
||||
history = normalize_action(history, self.action_dim)
|
||||
target = normalize_action(target, self.action_dim)
|
||||
if self._range_mode:
|
||||
history = normalize_action(history, self.action_dim,
|
||||
mode=self._range_mode)
|
||||
target = normalize_action(target, self.action_dim,
|
||||
mode=self._range_mode)
|
||||
history = torch.from_numpy(history.copy())
|
||||
target = torch.from_numpy(target.copy())
|
||||
if self._action_mean is not None:
|
||||
@@ -220,9 +226,10 @@ def main():
|
||||
# targets 计算并保存到 checkpoint_dir,验证集复用同一 stats;
|
||||
# fixed_range 模式免统计量,跳过此段
|
||||
action_mean = action_std = None
|
||||
if cfg.data.normalize_actions and cfg.data.normalize_mode == "fixed_range":
|
||||
# 构造时 action_mean=None 已置 _fixed_range,这里只打印口径
|
||||
print("动作归一化: fixed_range(固定物理范围 min-max→[0,1],免 stats)")
|
||||
if cfg.data.normalize_actions and is_range_mode(cfg.data.normalize_mode):
|
||||
# 构造时 action_mean=None 已置 _range_mode,这里只打印口径
|
||||
print(f"动作归一化: {cfg.data.normalize_mode}"
|
||||
f"(固定物理范围→[0,1],免 stats)")
|
||||
elif cfg.data.normalize_actions:
|
||||
if cfg.data.action_stats_path:
|
||||
if not os.path.exists(cfg.data.action_stats_path):
|
||||
|
||||
+93
-1
@@ -2074,7 +2074,7 @@ class TestFixedRangeNorm(unittest.TestCase):
|
||||
image_width=32, image_height=32, frame_len=3,
|
||||
normalize_actions=True)
|
||||
self.assertIsNotNone(ds._action_mean)
|
||||
self.assertFalse(ds._fixed_range)
|
||||
self.assertIsNone(ds._range_mode)
|
||||
|
||||
def test_mt_dataset_fixed_range(self):
|
||||
"""MTActionDataset fixed_range:免统计量,按物理范围归一化"""
|
||||
@@ -2254,6 +2254,98 @@ class TestR2ImuNorm(unittest.TestCase):
|
||||
out, ([1.0, -1.0] + [1.0] * 6) * 2, atol=1e-6)
|
||||
|
||||
|
||||
class TestLogRangeNorm(unittest.TestCase):
|
||||
"""log_range 归一化:spk 两列对数尺度,其余列与 fixed_range 一致"""
|
||||
|
||||
def test_spk_cols_log_normalized(self):
|
||||
"""spk.F/spk.delay 按 log10(1+x)/log10(1+hi) 归一化,低端值铺开"""
|
||||
from action_norm import normalize_action, action_bounds
|
||||
a = np.zeros(18, dtype=np.float32)
|
||||
a[10] = 10.0 # 常见频率:线性尺度 0.002 → 对数 0.27
|
||||
a[11] = 10.0 # 常见时延:线性尺度 0.005 → 对数 0.31
|
||||
out = normalize_action(a, mode="log_range")
|
||||
_, hi = action_bounds(18)
|
||||
self.assertAlmostEqual(float(out[10]),
|
||||
float(np.log10(11.0) / np.log10(hi[10] + 1.0)),
|
||||
places=6)
|
||||
self.assertAlmostEqual(float(out[11]),
|
||||
float(np.log10(11.0) / np.log10(hi[11] + 1.0)),
|
||||
places=6)
|
||||
self.assertGreater(float(out[10]), 0.25) # 不再挤在 0.002
|
||||
self.assertGreater(float(out[11]), 0.25)
|
||||
|
||||
def test_servo_cols_stay_linear(self):
|
||||
"""log_range 下舵机列与 fixed_range 完全一致"""
|
||||
from action_norm import normalize_action
|
||||
a = np.zeros(18, dtype=np.float32)
|
||||
a[:10] = [0.0, 45.0, 90.0, 135.0, 180.0, 0.0, 45.0, 90.0, 135.0, 180.0]
|
||||
lin = normalize_action(a, mode="fixed_range")
|
||||
log = normalize_action(a, mode="log_range")
|
||||
np.testing.assert_allclose(log[:10], lin[:10], atol=1e-7)
|
||||
np.testing.assert_allclose(log[12:], lin[12:], atol=1e-7)
|
||||
|
||||
def test_log_roundtrip(self):
|
||||
"""log_range 归一化→反归一化往返一致(spk 列含 0 与常规值)"""
|
||||
from action_norm import normalize_action, denormalize_action
|
||||
rng = np.random.RandomState(1)
|
||||
a = np.zeros((9, 18), dtype=np.float32)
|
||||
a[:, :10] = rng.uniform(0, 180, (9, 10))
|
||||
a[:, 10] = [0.0, 10.0, 100.0, 1000.0, 5000.0, 0.0, 10.0, 100.0, 1000.0]
|
||||
a[:, 11] = [0.0, 10.0, 200.0, 2000.0, 0.0, 10.0, 200.0, 2000.0, 500.0]
|
||||
rt = denormalize_action(normalize_action(a, mode="log_range"),
|
||||
mode="log_range")
|
||||
np.testing.assert_allclose(rt, a, rtol=1e-5, atol=1e-4)
|
||||
|
||||
def test_negative_log_col_clipped(self):
|
||||
"""log 列负值先截 0(log10(1+x) 定义域 x>-1),不产生 NaN"""
|
||||
from action_norm import normalize_action
|
||||
a = np.zeros(18, dtype=np.float32)
|
||||
a[10] = -50.0
|
||||
a[11] = -1.0
|
||||
out = normalize_action(a, mode="log_range")
|
||||
self.assertEqual(float(out[10]), 0.0)
|
||||
self.assertEqual(float(out[11]), 0.0)
|
||||
self.assertTrue(np.all(np.isfinite(out)))
|
||||
|
||||
def test_dataset_log_range_mode(self):
|
||||
"""dataset normalize_mode="log_range" 时目标按对数尺度归一化"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
base = Path(tmpdir)
|
||||
ep_dir = base / "episode_0001"
|
||||
images_dir = ep_dir / "images"
|
||||
images_dir.mkdir(parents=True)
|
||||
for i in range(8):
|
||||
Image.fromarray(np.random.randint(
|
||||
0, 255, (16, 16, 3), dtype=np.uint8)).save(
|
||||
images_dir / f"{i:04d}.jpg")
|
||||
actions = np.zeros((8, 12), dtype=np.float32)
|
||||
actions[:, 0] = 90.0 # 舵机 0.5
|
||||
actions[:, 10] = 10.0 # spk.F → log 尺度
|
||||
actions[:, 11] = 10.0 # spk.delay → log 尺度
|
||||
np.save(ep_dir / "actions.npy", actions)
|
||||
ds = ActionDataset(tmpdir, history_len=3, action_dim=12,
|
||||
image_width=16, image_height=16, frame_len=3,
|
||||
normalize_actions=True,
|
||||
normalize_mode="log_range")
|
||||
self.assertIsNone(ds._action_mean)
|
||||
self.assertIsNone(ds._action_std)
|
||||
s = ds[3]
|
||||
tgt = s["target"].numpy()
|
||||
self.assertAlmostEqual(float(tgt[0]), 0.5, places=6)
|
||||
self.assertAlmostEqual(float(tgt[10]),
|
||||
float(np.log10(11.0) / np.log10(5001.0)),
|
||||
places=6)
|
||||
|
||||
def test_config_accepts_log_range(self):
|
||||
"""normalize_mode="log_range" 通过校验;非法模式拒绝"""
|
||||
cfg = Config()
|
||||
cfg.data.normalize_mode = "log_range"
|
||||
validate_config_values(cfg.data, cfg.model)
|
||||
cfg.data.normalize_mode = "log10"
|
||||
with self.assertRaises(ValueError):
|
||||
validate_config_values(cfg.data, cfg.model)
|
||||
|
||||
|
||||
class TestR2ImuModel(unittest.TestCase):
|
||||
"""R2 方案一模型:独立 IMU token 注入(序列 F+H+2)"""
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# actpred 多头 one-hot 分类输出开发方案
|
||||
|
||||
> 文档类型:开发方案
|
||||
> 模块:actpred(联动 pybot 数据契约)
|
||||
> 作者:自动生成
|
||||
> 日期:2026-08-14
|
||||
> 前置实验:`pybot_actpred_dataset_training_eval_20260814.md`、
|
||||
> `actpred_fixed_range_weighted_experiment_20260814.md`(模型 A-E 系列)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
模型 A-E 系列实验定位到两个结构性难题:
|
||||
|
||||
1. **尺度放大**:fixed_range 线性 min-max 下 spk.F 的 1 个归一化单位 = 5000Hz 原始量,闭环反馈中误差滚雪球(模型 C/D 发散根因之一)
|
||||
2. **精度错配**:常用值(10Hz → 0.002)挤在线性尺度的低端,[0,1] 精度预算被浪费(log_range 正在验证的对数尺度解法)
|
||||
|
||||
多头 one-hot 是本问题的**结构性解法**:把 18 维连续回归换成 18 组独立分类头,每组输出一个 one-hot 分布。分类天然消除尺度放大(每箱等距)与物理界问题(箱只覆盖物理范围),且多列 softmax 是 NPU(Hailo)最擅长的计算形态。
|
||||
|
||||
## 2. 方案设计
|
||||
|
||||
### 2.1 分箱设计(每组 one-hot 的维度)
|
||||
|
||||
| 列组 | 分箱方式 | 箱数/列 | 依据 |
|
||||
|---|---|---|---|
|
||||
| 舵机 0-9(10 列) | 均匀 **1°/箱** | 180 | 匹配数据粒度:微动作插值 0.75°/步,5° 箱会把相邻步量化进同一箱、毁掉插值结构 |
|
||||
| spk.F(10) | 对数分箱 | 32 | 频率对数感知,低端聚集;log10(1+x) 均匀切 |
|
||||
| spk.delay(11) | 对数分箱 | 32 | 同左 |
|
||||
| 保留位 12-17 | 无头 | 0 | 恒 0,不参与 |
|
||||
|
||||
**输出总量**:10×180 + 32 + 32 = **1864 logits**(当前 18)。最后层 128→1864 ≈ 238K 参数,模型 409K → ~650K,仍属轻量。
|
||||
|
||||
### 2.2 各层改动
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `actpred/bins.py`(新增) | 分箱表 + `encode(x)→one-hot` / `decode(p)→连续值`(含对数箱);纯 numpy,供推理/部署复用 |
|
||||
| `model.py` | PredictionHead 输出 1864 维 → reshape (B, 18, bins) → 逐组 softmax |
|
||||
| `dataset.py` | target 从 (B,18) 连续值 → 每组 one-hot;**软标签**:真值高斯扩散到 ±1-2 箱 |
|
||||
| `train.py` | 损失 = Σ 列权重 × CE(组输出, one-hot 真值);列权重沿用 default_column_weights |
|
||||
| `predict.py` | 输出 decode:**期望值** `Σ pᵢ·箱中心`(推荐)或 argmax 箱中心 → 18 维原始量纲 |
|
||||
| `evaluate.py` | 无感——predict 返回仍是 18 维原始量纲 |
|
||||
| `export_deploy` / Hailo | 多组 softmax 为 NPU 友好形态 |
|
||||
|
||||
### 2.3 解码策略(执行时如何取连续值)
|
||||
|
||||
| 方式 | 行为 | 评估 |
|
||||
|---|---|---|
|
||||
| **期望值**(推荐) | `Σ pᵢ × 箱中心` | 平滑连续,分辨率超越箱宽(1° 箱可出 46.4°);单峰假设 |
|
||||
| argmax | 取最大概率箱中心 | 确定性,量化到 1° |
|
||||
| 按分布采样 | 随机抽箱 | 探索用,后续离线 RL 可复用 |
|
||||
|
||||
## 3. 关键决策点与理由
|
||||
|
||||
1. **舵机 1°/箱而非 5°/箱**:录制数据的微动作插值粒度为 0.75°,箱宽必须匹配数据粒度;5° 箱会把 45.0°/45.75° 压进同一箱,模型学不到特意录制的插值结构
|
||||
2. **软标签**:804 样本对 1864 logits 偏少,多数箱极少被访问。真值 one-hot 高斯扩散到邻近箱,未访问箱也能获得梯度
|
||||
3. **期望值解码补序数信息**:one-hot 丢失"45° 和 46° 相邻"先验,期望值解码在推理端部分恢复;训练端靠软标签缓解
|
||||
4. **pybot 契约零变化**:predict 输出仍是 18 维原始量纲,闭环与录制数据无感知
|
||||
5. **与现有归一化链路的兼容**:one-hot 方案独立于 zscore/fixed_range/log_range——分箱编码替代归一化,不叠加使用
|
||||
|
||||
## 4. 实施步骤
|
||||
|
||||
| 步骤 | 内容 | 产出 |
|
||||
|---|---|---|
|
||||
| 1 | `bins.py`:分箱表 + encode/decode + 对数箱 + 软标签工具 | 独立纯 numpy 模块 + 单测 |
|
||||
| 2 | `dataset.py`:目标 one-hot 化(软标签可开关) | 数据链路 + 单测 |
|
||||
| 3 | `model.py` + `train.py`:多头 softmax + 加权 CE | 模型与训练 + 单测 |
|
||||
| 4 | `predict.py`:期望值/argmax 解码集成 + 冒烟 | 推理链路 + 单测 |
|
||||
| 5 | 训练模型 F,与 A-E 闭环五方对比 | 对比报告 |
|
||||
|
||||
## 5. 与其他方案的定位
|
||||
|
||||
- **模型 E(log_range)正在训练**:若对数尺度已解决精度错配与闭环发散,one-hot 降级为可选优化(分类头天然有界、免归一化常量表的优势仍在)
|
||||
- 若 E 仍失败:one-hot 是下一个强候选(结构性消除尺度放大)
|
||||
- **互补关系**:one-hot 方案与逐列权重、R1-B rollout 正交,可叠加(rollout 治 exposure bias 的结论不变,任何损失形式都需要它)
|
||||
|
||||
## 6. 决策记录
|
||||
|
||||
- **箱宽**:初拟 5°/箱(保守,顾虑数据量),讨论后定为 1°/箱——数据插值粒度 0.75° 决定箱宽下限;spk 两列对数 32 箱
|
||||
- **单头多选 vs 多头单选**:softmax 单头在数学上是单选择分布;"多组 one-hot" = 18 个独立 softmax 头并行,每列一个选择
|
||||
- **多峰分布**:当前数据单峰为主,期望值解码够用;若未来出现双峰(如跨脚本姿态冲突),升级为逐箱 sigmoid 或 MDN
|
||||
Reference in new issue
Block a user