feat(actpred): 多头 one-hot 分类输出——18 组独立 softmax 头替代连续回归
分箱(bins.py):舵机 1°/箱 ×10(180 箱,匹配 0.75° 微动作插值粒度)、 spk.F/spk.delay 对数 32 箱 ×2,共 1864 logits;保留位无头。软标签 (onehot_sigma=1.0 高斯扩散)缓解小样本数据稀疏;期望值解码恢复 连续分辨率(箱生活在原始空间,无反变换放大)。 训练损失为逐头加权 CE(列权重沿用),闭环反馈经解码→范围归一化 回写历史;predict 输出仍为 18 维原始量纲,pybot 契约零变化。 结构性消除尺度放大(每箱等距)与物理界问题(箱天然有界), 为 A-E 归一化实验失败后的正解路线。要求 normalize_mode 为范围类, 与 predict_delta 互斥。测试 +10(含 CLI 映射回归)。
This commit is contained in:
1 parent
99c1bc1739
commit
a18a7de1ca
7 files changed
+409
-20
No files matched your search
@@ -2347,6 +2347,176 @@ class TestLogRangeNorm(unittest.TestCase):
|
||||
validate_config_values(cfg.data, cfg.model)
|
||||
|
||||
|
||||
class TestOneHot(unittest.TestCase):
|
||||
"""多头 one-hot 输出:bins.py 编解码、软标签、dataset 目标、加权 CE、
|
||||
config 校验"""
|
||||
|
||||
# ---------------- bins.py ----------------
|
||||
|
||||
def test_encode_hard_onehot(self):
|
||||
from bins import encode, START, BINS_PER_COL
|
||||
a = np.zeros((2, 18), dtype=np.float32)
|
||||
a[0, 0] = 45.0
|
||||
a[0, 10] = 10.0
|
||||
a[0, 11] = 200.0
|
||||
a[1, 0] = 46.5
|
||||
o = encode(a)
|
||||
self.assertEqual(o.shape, (2, 1864))
|
||||
# 每行 12 头各一个 1,其余 0
|
||||
self.assertEqual(float(o.sum()), 24.0)
|
||||
self.assertEqual(np.argmax(o[0, START[0]:START[1]]), 45)
|
||||
self.assertEqual(np.argmax(o[1, START[0]:START[1]]), 46)
|
||||
# 保留位列无头,恒 0
|
||||
np.testing.assert_allclose(o[:, START[12]:], 0.0)
|
||||
|
||||
def test_encode_log_bins_spread_low_end(self):
|
||||
"""对数箱:10Hz 与 1000Hz 落在不同箱,低端不再挤在一起"""
|
||||
from bins import encode, START
|
||||
a = np.zeros((2, 18), dtype=np.float32)
|
||||
a[0, 10] = 10.0
|
||||
a[1, 10] = 1000.0
|
||||
o = encode(a)
|
||||
b0 = np.argmax(o[0, START[10]:START[11]])
|
||||
b1 = np.argmax(o[1, START[10]:START[11]])
|
||||
self.assertNotEqual(b0, b1)
|
||||
self.assertGreater(b1, b0)
|
||||
|
||||
def test_encode_soft_label(self):
|
||||
"""软标签:真值附近多箱非零且组内概率和为 1"""
|
||||
from bins import encode, START
|
||||
a = np.zeros((1, 18), dtype=np.float32)
|
||||
a[0, 0] = 45.0
|
||||
o = encode(a, sigma=1.0)
|
||||
head = o[0, START[0]:START[1]]
|
||||
self.assertGreater(np.count_nonzero(head), 1)
|
||||
self.assertAlmostEqual(float(head.sum()), 1.0, places=6)
|
||||
# 峰值在真值箱
|
||||
self.assertEqual(np.argmax(head), 45)
|
||||
|
||||
def test_decode_expectation(self):
|
||||
"""期望值解码:与真值一致时还原箱中心;子箱分辨率由概率内插"""
|
||||
from bins import encode, decode, START, BINS_PER_COL
|
||||
a = np.zeros((1, 18), dtype=np.float32)
|
||||
a[0, 0] = 45.0
|
||||
a[0, 10] = 10.0
|
||||
logits = np.log(encode(a) + 1e-8)
|
||||
d = decode(logits)
|
||||
self.assertAlmostEqual(float(d[0, 0]), 45.5, places=3) # 箱中心
|
||||
self.assertGreater(float(d[0, 10]), 10.0) # 对数箱中心偏右
|
||||
# 混合概率内插:45 箱 0.6 / 46 箱 0.4 → 45.9
|
||||
# (背景须 -1e9 使尾部概率可忽略;-10 背景的尾部质量 × 高箱中心
|
||||
# 会贡献 ~0.7°,属测试构造假象)
|
||||
logits2 = np.full((1, 1864), -1e9, dtype=np.float32)
|
||||
logits2[0, START[0] + 45] = np.log(0.6)
|
||||
logits2[0, START[0] + 46] = np.log(0.4)
|
||||
d2 = decode(logits2)
|
||||
self.assertAlmostEqual(float(d2[0, 0]), 45.9, places=4)
|
||||
|
||||
def test_decode_preserves_reserved_cols_zero(self):
|
||||
from bins import decode
|
||||
logits = np.zeros((2, 1864), dtype=np.float32)
|
||||
d = decode(logits)
|
||||
np.testing.assert_allclose(d[:, 12:], 0.0)
|
||||
|
||||
# ---------------- dataset 链路 ----------------
|
||||
|
||||
def test_dataset_onehot_targets(self):
|
||||
"""onehot 模式:目标为多组 one-hot,历史仍按范围归一化"""
|
||||
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
|
||||
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="fixed_range",
|
||||
onehot=True, onehot_sigma=0.0)
|
||||
s = ds[3]
|
||||
tgt = s["target"]
|
||||
self.assertEqual(tgt.shape, (1864,))
|
||||
from bins import START
|
||||
self.assertEqual(np.argmax(tgt.numpy()[START[0]:START[1]]), 90)
|
||||
# 历史仍为连续归一化值(90° → 0.5)
|
||||
np.testing.assert_allclose(
|
||||
s["action_history"].numpy()[:, 0], 0.5, atol=1e-6)
|
||||
|
||||
# ---------------- 加权 CE ----------------
|
||||
|
||||
def test_onehot_ce(self):
|
||||
from train import _onehot_ce
|
||||
from bins import encode, START
|
||||
logits = torch.zeros(2, 1864)
|
||||
a = np.zeros((2, 18), dtype=np.float32)
|
||||
a[0, 0] = 45.0
|
||||
a[1, 0] = 90.0
|
||||
targets = torch.from_numpy(encode(a, sigma=0.0))
|
||||
w = torch.tensor([1.0] * 10 + [0.2, 0.0] + [0.0] * 6)
|
||||
# 全部 12 头都预测正确 → CE≈0
|
||||
logits = torch.log(targets + 1e-9)
|
||||
loss = _onehot_ce(logits, targets, w, 18)
|
||||
self.assertLess(float(loss), 1e-3)
|
||||
# 第 2 行 head0 错箱(预测 bin 0,真值 90)→ 加权 CE 上升
|
||||
bad = logits.clone()
|
||||
bad[1, START[0]] = 0.0
|
||||
bad[1, START[0] + 90] = float(np.log(1e-9))
|
||||
loss_bad = _onehot_ce(bad, targets, w, 18)
|
||||
self.assertGreater(float(loss_bad), 0.5)
|
||||
|
||||
# ---------------- config 校验 ----------------
|
||||
|
||||
def test_onehot_cli_override_applied(self):
|
||||
"""--onehot CLI 标志经 apply_overrides 落到 cfg.data.onehot(回归:
|
||||
曾漏映射导致静默退化为回归训练)"""
|
||||
from config import build_argparser, apply_overrides
|
||||
parser = build_argparser("t")
|
||||
args = parser.parse_args(["--onehot", "--onehot-sigma", "2.0"])
|
||||
cfg = apply_overrides(Config(), args)
|
||||
self.assertTrue(cfg.data.onehot)
|
||||
self.assertEqual(cfg.data.onehot_sigma, 2.0)
|
||||
# 缺省不开启
|
||||
args = parser.parse_args([])
|
||||
cfg = apply_overrides(Config(), args)
|
||||
self.assertFalse(cfg.data.onehot)
|
||||
|
||||
def test_onehot_config_validation(self):
|
||||
cfg = Config()
|
||||
cfg.data.onehot = True
|
||||
cfg.data.normalize_actions = True
|
||||
cfg.data.normalize_mode = "fixed_range"
|
||||
validate_config_values(cfg.data, cfg.model, cfg.train)
|
||||
# 与 predict_delta 互斥
|
||||
cfg.train.predict_delta = True
|
||||
with self.assertRaises(ValueError):
|
||||
validate_config_values(cfg.data, cfg.model, cfg.train)
|
||||
# 要求范围类归一化
|
||||
cfg.train.predict_delta = False
|
||||
cfg.data.normalize_mode = "zscore"
|
||||
with self.assertRaises(ValueError):
|
||||
validate_config_values(cfg.data, cfg.model, cfg.train)
|
||||
|
||||
def test_onehot_model_output_shape(self):
|
||||
"""onehot 模型前向输出 TOTAL_BINS logits"""
|
||||
cfg = Config()
|
||||
cfg.data.onehot = True
|
||||
cfg.data.normalize_mode = "fixed_range"
|
||||
cfg.data.image_width = 32
|
||||
cfg.data.image_height = 32
|
||||
m = ActionPredictor(cfg.model, cfg.data, pretrained=False)
|
||||
from bins import TOTAL_BINS
|
||||
images = torch.randn(2, cfg.data.frame_len, 3, 32, 32)
|
||||
history = torch.randn(2, cfg.data.history_len, cfg.data.action_dim)
|
||||
out = m(images, history)
|
||||
self.assertEqual(out.shape, (2, TOTAL_BINS))
|
||||
|
||||
|
||||
class TestR2ImuModel(unittest.TestCase):
|
||||
"""R2 方案一模型:独立 IMU token 注入(序列 F+H+2)"""
|
||||
|
||||
|
||||
Reference in new issue
Block a user