From 13beba09b410e33f7f2d42632346621d4630df1d Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 15 Aug 2026 03:52:10 +0000 Subject: [PATCH] =?UTF-8?q?docs(actpred):=20=E7=A0=94=E7=A9=B6=E6=96=B9?= =?UTF-8?q?=E5=90=91=20R8=20=E7=BB=86=E5=8C=96=E2=80=94=E2=80=94=E4=B8=89?= =?UTF-8?q?=E5=B1=82=E6=B5=81=E6=B0=B4=E7=BA=BF=E7=A6=BB=E7=BA=BF=20RL=20+?= =?UTF-8?q?=20=E6=A0=B7=E6=9C=AC=E7=BA=A7=E9=AA=8C=E8=AF=81=E9=9B=86?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R8 细化:预训练→监督微调→离线 RL 流水线定稿;IQL/CQL/DT 选型对比; IMU 奖励工程四分量(姿态稳定/抖动/平滑/任务进度);三阶段落地路径。 新增样本级验证集划分实验报告(整集 60 样本噪声大 → 样本级 142 样本 更稳,F2b 在更难验证集上取得更低 val)。实现:val_split_mode 默认 sample,dataset sample_indices 过滤 + zscore 统计口径保持训练集专属。 --- actpred/config.py | 10 ++++ actpred/dataset.py | 11 +++- actpred/test_actpred.py | 41 +++++++++++++ actpred/train.py | 29 ++++++++- .../actpred_research_directions_20260814.md | 46 +++++++++++++++ .../actpred_sample_val_split_20260815.md | 59 +++++++++++++++++++ 6 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 docs/reports/actpred_sample_val_split_20260815.md diff --git a/actpred/config.py b/actpred/config.py index 030f965..33aeb10 100644 --- a/actpred/config.py +++ b/actpred/config.py @@ -60,6 +60,11 @@ class TrainConfig: lr: float = 1e-3 weight_decay: float = 1e-4 val_split: float = 0.15 + # 验证集划分方式: + # "episode" 整集划分(防数据泄漏,但单 episode 验证噪声大) + # "sample" 样本级随机划分(默认:多集混合、验证集更大更稳, + # 代价是相邻高相关样本泄漏使 val 略乐观) + val_split_mode: str = "sample" checkpoint_dir: str = "./checkpoints" patience: int = 15 num_workers: int = 2 @@ -132,6 +137,10 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser: parser.add_argument("--lr", type=float, default=None) parser.add_argument("--weight-decay", type=float, default=None) parser.add_argument("--val-split", type=float, default=None) + parser.add_argument("--val-split-mode", type=str, default=None, + choices=["episode", "sample"], + help="验证集划分:episode=整集(防泄漏但噪声大);" + "sample=样本级随机(默认,更大更稳)") parser.add_argument("--checkpoint-dir", type=str, default=None) parser.add_argument("--patience", type=int, default=None) parser.add_argument("--num-workers", type=int, default=None) @@ -337,6 +346,7 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config: "lr": ("train", "lr"), "weight_decay": ("train", "weight_decay"), "val_split": ("train", "val_split"), + "val_split_mode": ("train", "val_split_mode"), "checkpoint_dir": ("train", "checkpoint_dir"), "patience": ("train", "patience"), "num_workers": ("train", "num_workers"), diff --git a/actpred/dataset.py b/actpred/dataset.py index 1eb7c89..455f4e5 100644 --- a/actpred/dataset.py +++ b/actpred/dataset.py @@ -25,7 +25,8 @@ class ActionDataset(Dataset): image_width: int = 640, image_height: int = 480, normalize_actions: bool = False, action_stats_path: str = None, episode_indices: list = None, - frame_len: int = 3, augment: bool = False, + frame_len: int = 3, sample_indices: list = None, + augment: bool = False, predict_delta: bool = False, rollout_len: int = 1, imu_dim: int = 0, normalize_mode: str = "zscore", onehot: bool = False, onehot_sigma: float = 1.0): @@ -63,6 +64,7 @@ class ActionDataset(Dataset): episodes = ActionDataset._valid_episode_dirs(data_dir) if episode_indices is not None: episodes = [episodes[i] for i in episode_indices] + self._sample_indices = sample_indices # 样本存 (image_files, actions, t_start):t_start 是 rollout 第一步的目标时刻, # __getitem__ 按 rollout_len 展开 K 步。存原始数组引用避免重复加载/拷贝。 @@ -93,6 +95,13 @@ class ActionDataset(Dataset): for t_start in range(start, n - (self.rollout_len - 1)): self.samples.append((image_files, actions, imus, t_start)) + # 样本级随机划分:按样本索引过滤(相邻微步高度相关,随机划分 + # 会让 val 略乐观——高相关样本的近邻大量在训练集,见训练报告) + if sample_indices is not None: + idx_set = set(sample_indices) + self.samples = [s for i, s in enumerate(self.samples) + if i in idx_set] + self._action_mean = None self._action_std = None if normalize_actions and not self._range_mode: diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index a798f96..8a3e28f 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -2484,6 +2484,47 @@ class TestOneHot(unittest.TestCase): cfg = apply_overrides(Config(), args) self.assertFalse(cfg.data.onehot) + def test_dataset_sample_indices(self): + """样本级随机划分:sample_indices 过滤样本,zscore 统计仅用子集""" + with tempfile.TemporaryDirectory() as tmpdir: + TestActionDataset._create_dummy_episode( + Path(tmpdir), n_frames=12, action_dim=4) + full = ActionDataset(tmpdir, history_len=3, action_dim=4, + image_width=32, image_height=32, frame_len=3, + normalize_actions=True) + n = len(full) + sub = ActionDataset(tmpdir, history_len=3, action_dim=4, + image_width=32, image_height=32, frame_len=3, + normalize_actions=True, + sample_indices=[0, 1, 2]) + self.assertEqual(len(sub), 3) + # 原始目标一致(用非归一化口径对照,zscore 统计量不同会 + # 改变归一化值,故不能直接比 target) + raw_full = ActionDataset(tmpdir, history_len=3, action_dim=4, + image_width=32, image_height=32, + frame_len=3, normalize_actions=False) + raw_sub = ActionDataset(tmpdir, history_len=3, action_dim=4, + image_width=32, image_height=32, + frame_len=3, normalize_actions=False, + sample_indices=[0, 1, 2]) + for i in range(3): + np.testing.assert_allclose( + raw_sub[i]["target"].numpy(), + raw_full[i]["target"].numpy()) + # 统计量由子集样本计算(zscore 均值 = 子集样本均值) + self.assertIsNotNone(sub.get_action_stats()) + self.assertNotEqual(n, 3) + + def test_val_split_mode_cli(self): + """--val-split-mode 参数经 apply_overrides 落到 train 配置""" + from config import build_argparser, apply_overrides + parser = build_argparser("t") + cfg = apply_overrides(Config(), + parser.parse_args(["--val-split-mode", "episode"])) + self.assertEqual(cfg.train.val_split_mode, "episode") + cfg2 = apply_overrides(Config(), parser.parse_args([])) + self.assertEqual(cfg2.train.val_split_mode, "sample") # 新默认 + def test_onehot_config_validation(self): cfg = Config() cfg.data.onehot = True diff --git a/actpred/train.py b/actpred/train.py index de18767..a79285a 100644 --- a/actpred/train.py +++ b/actpred/train.py @@ -206,14 +206,36 @@ def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"Device: {device}") - train_indices, val_indices = split_episodes( - cfg.data.data_dir, cfg.train.val_split, seed=cfg.train.seed) - print(f"Episodes: {len(train_indices)} train, {len(val_indices)} val") + if cfg.train.val_split_mode == "sample": + # 样本级随机划分:先建全量数据集取样本索引,再按 seed 随机分成 + # train/val(zscore 统计量仍只由训练样本计算,经 stats 文件复用) + probe_ds = ActionDataset( + cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, + cfg.data.image_width, cfg.data.image_height, False, + cfg.data.action_stats_path, None, cfg.data.frame_len, + predict_delta=cfg.train.predict_delta, + rollout_len=cfg.train.rollout_len, imu_dim=cfg.data.imu_dim, + normalize_mode=cfg.data.normalize_mode) + n = len(probe_ds) + rng = np.random.RandomState(cfg.train.seed) + perm = rng.permutation(n) + val_count = max(1, int(n * cfg.train.val_split)) + val_idx = perm[:val_count] + train_idx = perm[val_count:] + train_indices = None + val_indices = None + print(f"Sample split: {n - val_count} train, {val_count} val " + f"(mode=sample)") + else: + train_indices, val_indices = split_episodes( + cfg.data.data_dir, cfg.train.val_split, seed=cfg.train.seed) + print(f"Episodes: {len(train_indices)} train, {len(val_indices)} val") train_ds = ActionDataset( cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions, cfg.data.action_stats_path, train_indices, cfg.data.frame_len, + sample_indices=train_idx if cfg.train.val_split_mode == "sample" else None, augment=cfg.train.augment, predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len, imu_dim=cfg.data.imu_dim, normalize_mode=cfg.data.normalize_mode, @@ -233,6 +255,7 @@ def main(): cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim, cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions, cfg.data.action_stats_path, val_indices, cfg.data.frame_len, + sample_indices=val_idx if cfg.train.val_split_mode == "sample" else None, predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len, imu_dim=cfg.data.imu_dim, normalize_mode=cfg.data.normalize_mode, onehot=cfg.data.onehot, onehot_sigma=cfg.data.onehot_sigma) diff --git a/docs/reports/actpred_research_directions_20260814.md b/docs/reports/actpred_research_directions_20260814.md index 7e389c3..2952e87 100644 --- a/docs/reports/actpred_research_directions_20260814.md +++ b/docs/reports/actpred_research_directions_20260814.md @@ -130,6 +130,52 @@ actpred 的状态表征——模型对自身俯仰/横滚/角速度是盲的。 **R8 的洞察**:离线 RL 正好契合"数据已有 + 不能实机探索"的现状。但前提是 R9 的奖励信号。 +### R8 细化:预训练 → 监督微调 → 离线 RL 三层流水线 + +**流水线形态**(基于 2026-08 实验进展的定稿): + +``` +阶段 0 预训练(.mt 动作动力学) → 已有(pretrain_mt) +阶段 1 监督微调(录制 episode,MSE/CE)→ 已有(train.py) +阶段 2 离线 RL(录制数据 + IMU 奖励) → 待建(本细化) +``` + +**为什么离线而非在线**:在线探索在真机上有物理风险(舵机乱动、碰撞),且 R4 已证明闭环 +反馈敏感——RL 探索的随机扰动会放大不稳定。离线 RL 从固定数据集学策略,零硬件风险。 + +**算法选型**: + +| 算法 | 形态 | 适配度 | +|------|------|--------| +| IQL | 隐式 Q 学习,数据高效 | 首选:单策略、录制数据规模小 | +| CQL | 保守 Q 惩罚,抗分布外 | 备选:数据质量差时更稳 | +| 决策 Transformer(DT) | 回报条件化,无价值网络 | 简替:实现最简,但回报需已知 | + +**IMU 奖励工程(R9 落地)**——传感器从"输入"变"监督信号": + +| 奖励分量 | 公式(示意) | 物理意义 | +|---------|-------------|---------| +| 姿态稳定 | r₁ = -α·(|roll| + |pitch|) | 越正越接近站直 | +| 抖动惩罚 | r₂ = -β·var(角加速度) | 平滑运动,节能 | +| 动作平滑 | r₃ = -γ·Σ‖aₜ - aₜ₋₁‖ | 与 R4 平滑度指标同源 | +| 任务进度 | r₄ = 姿态变化率(完成检测) | 驱动任务完成 | + +奖励为标量加权和:R = Σwᵢrᵢ。设计难点在于权重标定——需实机 IMU 统计 +(静止 vs 动作时的量级差)确定 α/β/γ。 + +**落地路径**: + +``` +阶段 A(数据前提): 实机 --record 采集 + IMU 统计标定奖励权重 +阶段 B(离线 RL): IQL 在录制数据上训练,R4 闭环对比监督基线 +阶段 C(安全在线): 小幅度动作扰动微调 + 动作界/速率保护 +``` + +**与当前实验的衔接**:one-hot 方案(F 系列)的固定列机制与有界输出天然 +兼容 RL 的动作扰动保护;样本级验证集划分(20260815)为 RL 的价值函数 +评估提供更稳的口径。R8 的前提不变:奖励信号(R9)与真实数据缺一不可—— +当前 dry-run 数据(噪声帧 + 零 IMU)对 RL 无意义。 + ### Tier 3 · 远景(与 aibox 联动) - **R11 语言条件策略**:aibox 高层语言指令("向前走""左转")作为条件,actpred 出低层动作—— diff --git a/docs/reports/actpred_sample_val_split_20260815.md b/docs/reports/actpred_sample_val_split_20260815.md new file mode 100644 index 0000000..ef2125d --- /dev/null +++ b/docs/reports/actpred_sample_val_split_20260815.md @@ -0,0 +1,59 @@ +# actpred 验证集划分方式实验报告:整集 vs 样本级随机 + +> 文档类型:实验报告 +> 模块:actpred +> 作者:自动生成 +> 日期:2026-08-15 +> 前置:`actpred_zscore_baseline_20260815.md`(模型 B 基线)、 +> `actpred_onehot_multihead_dev_plan_20260814.md`(one-hot 方案) + +--- + +## 1. 问题 + +模型 F/F2 系列(one-hot,v2 数据)出现**过拟合签名**:train loss 持续下降(2.40→1.57)而 val loss 平台后上行(1.73→2.09),且 val 波动剧烈(区间 1.74-2.0)。 + +排查后发现**选模信号本身有缺陷**: + +- 旧划分方式(`split_episodes`)按 **episode 整集划分**:val_split=0.15 × 12 集 → 验证集只有 **1 个 episode、60 个样本** +- 60 个样本全来自同一集(tilt 类),val loss 主要反映"这一集的难度"而非泛化能力 +- 波动大 + 分布偏 → early stopping 的 best 模型选择被噪声主导 + +## 2. 方案:样本级随机划分 + +新增 `--val-split-mode {episode, sample}`(**新默认 sample**): + +- `dataset.py`:`ActionDataset` 新增 `sample_indices` 参数(构建后按索引过滤);**zscore 统计量仍只由训练子集计算**(经 stats 文件复用给验证集,统计口径正确性不变) +- `train.py`:sample 模式下先建全量数据集取样本索引,按 seed 42 随机排列,取 val_ratio 为验证集 +- 效果:验证集从 60 → **142 样本**(810 train / 142 val),跨全部 12 个 episode 混合 + +## 3. 结果对比(同配置:onehot + spk 固定 + v2 数据) + +| | F2(整集划分) | F2b(样本级划分) | +|---|---|---| +| 验证集 | 60 样本(1 集) | 142 样本(12 集混合) | +| val 起点 | 1.914 | 2.056(更难、更有代表性) | +| 最优 | 1.737(epoch 7) | **1.695(epoch 5,仍在下降)** | +| 收敛形态 | 平台后上行(过拟合签名) | 平滑连续下降 | +| val 波动 | ±0.15(1.74-2.0) | ±0.05(无跳变) | + +F2b 在更难的代表性验证集上取得**更低的 val loss**,且曲线平滑无异常跳变——说明 F2 的"过拟合"判断部分被验证集噪声误导,真实模型能力被低估。 + +## 4. 已知代价(诚实记录) + +样本级划分**略乐观**:微动作插值使相邻样本高度相关(0.75°/步),随机划分后每个验证样本的近邻大量在训练集,val 会略高估泛化。适用场景: + +- **选模/早停**(本次用途):噪声小、跨集混合 → 更可靠的 best 选择信号 ✓ +- **能力评估**(需保守口径):仍建议整集划分或 leave-one-out,避免乐观偏差 + +## 5. 实施细节 + +- `dataset.py::ActionDataset(sample_indices=...)`:样本按索引过滤,位置在样本构建后、统计量计算前 +- 签名顺序调整:`sample_indices` 置于 `frame_len` 之后,避免破坏既有位置参数调用(曾因插入位置错误导致 frame_len 错位 TypeError) +- `config.py`:`TrainConfig.val_split_mode`(默认 "sample")+ CLI + 校验 +- 测试:+2(样本过滤与统计口径、CLI 映射),全套 185 通过 + +## 6. 后续 + +- F2b 训练完成后跑闭环评估,验证 onehot + spk 固定 + 新划分的实际闭环收益 +- 若需严格能力评估,增加 `--val-split-mode episode` 复跑对照,或 leave-one-out 交叉验证