actpred: 代码审计(16项发现全部修复)+ 文档更新
审计报告: docs/reviews/actpred_CODE_AUDIT_20260808.md - P0(2项): episode索引错位(dataset.py)、推理归一化静默失败(predict.py) - P1(6项): 空训练集校验、图像自然排序、空样本防御、形状校验、 MobileNet下载错误提示、stats路径校验 - P2(8项): README示例修正、公共属性、glob优化、版本约束、 单元测试(17项)、GPU安装说明、BooleanOptionalAction、ONNX验证 新增文件: actpred/test_actpred.py (17项单元测试, 全部通过) 更新文档: README.md配置表扩充、INDEX.md、审计报告修复总结
This commit is contained in:
1 parent
1e34348d6c
commit
3d03f7a1ce
19 files changed
+850
-177
No files matched your search
+18
-4
@@ -17,18 +17,21 @@
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
# 安装(默认 CPU-only;如需 GPU 支持,编辑 _set.sh 按注释说明操作)
|
||||
cd actpred && bash _set.sh
|
||||
|
||||
# 训练
|
||||
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 8
|
||||
|
||||
# 推理
|
||||
# 推理(--action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步)
|
||||
bash _run.sh --model checkpoints/best_inference.pt --image frame.jpg \
|
||||
--action-history "45,0,45,0,45,0,45,0:45,10,45,10,45,10,45,10"
|
||||
--action-history "0,0,0,0,0,0,0,0:1,0,0,0,0,0,0,0:0,1,0,0,0,0,0,0:0,0,1,0,0,0,0,0:0,0,0,1,0,0,0,0"
|
||||
|
||||
# 导出 ONNX
|
||||
# 导出 ONNX(如安装了 onnxruntime,导出后自动验证一致性)
|
||||
bash _run.sh export --model checkpoints/best_inference.pt --output model.onnx
|
||||
|
||||
# 运行单元测试
|
||||
python test_actpred.py
|
||||
```
|
||||
|
||||
## 数据格式
|
||||
@@ -72,6 +75,15 @@ recorder.flush()
|
||||
| `--epochs` | 100 | 训练轮数 |
|
||||
| `--lr` | 0.001 | 学习率 |
|
||||
| `--batch-size` | 32 | 批次大小 |
|
||||
| `--normalize-actions` | False | 动作归一化(`--no-normalize-actions` 关闭) |
|
||||
| `--dropout` | 0.1 | Dropout 率 |
|
||||
| `--weight-decay` | 0.0001 | 权重衰减 |
|
||||
| `--val-split` | 0.15 | 验证集比例 |
|
||||
| `--patience` | 15 | 早停耐心值 |
|
||||
| `--max-grad-norm` | 1.0 | 梯度裁剪上限 |
|
||||
| `--seed` | 42 | 随机种子 |
|
||||
| `--num-workers` | 2 | DataLoader 工作进程数 |
|
||||
| `--checkpoint-dir` | ./checkpoints | 检查点保存目录 |
|
||||
|
||||
## 编程 API
|
||||
|
||||
@@ -79,5 +91,7 @@ recorder.flush()
|
||||
from predict import ActionPredictorInference
|
||||
|
||||
predictor = ActionPredictorInference("checkpoints/best_inference.pt")
|
||||
print(predictor.action_dim, predictor.history_len) # 公共属性
|
||||
action = predictor.predict(image_rgb, action_history) # np.ndarray (action_dim,)
|
||||
# action_history 形状须为 (history_len, action_dim)
|
||||
```
|
||||
@@ -4,6 +4,7 @@ python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# CPU-only PyTorch (no CUDA dependency)
|
||||
# 如需 GPU 支持,注释掉下面一行并改用:pip install torch torchvision
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
# Remaining deps from Tsinghua mirror
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
|
||||
parser.add_argument("--action-dim", type=int, default=None)
|
||||
parser.add_argument("--history-len", type=int, default=None)
|
||||
parser.add_argument("--data-dir", type=str, default=None)
|
||||
parser.add_argument("--normalize-actions", action="store_true", default=None)
|
||||
parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None)
|
||||
parser.add_argument("--action-stats-path", type=str, default=None)
|
||||
parser.add_argument("--backbone", type=str, default=None, choices=["simple", "mobilenet"])
|
||||
parser.add_argument("--cnn-feature-dim", type=int, default=None)
|
||||
|
||||
+21
-6
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
@@ -8,6 +9,11 @@ from torch.utils.data import Dataset
|
||||
from torchvision import transforms
|
||||
|
||||
|
||||
def _natural_key(p):
|
||||
"""自然排序键:'1.jpg' < '2.jpg' < ... < '10.jpg'"""
|
||||
return [int(t) if t.isdigit() else t for t in re.split(r'(\d+)', p.name)]
|
||||
|
||||
|
||||
class ActionDataset(Dataset):
|
||||
def __init__(self, data_dir: str, history_len: int, action_dim: int,
|
||||
image_size: int = 64, normalize_actions: bool = False,
|
||||
@@ -22,8 +28,7 @@ class ActionDataset(Dataset):
|
||||
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
|
||||
])
|
||||
|
||||
data_path = Path(data_dir)
|
||||
episodes = sorted([d for d in data_path.iterdir() if d.is_dir()])
|
||||
episodes = ActionDataset._valid_episode_dirs(data_dir)
|
||||
if episode_indices is not None:
|
||||
episodes = [episodes[i] for i in episode_indices]
|
||||
|
||||
@@ -36,7 +41,7 @@ class ActionDataset(Dataset):
|
||||
actions = np.load(actions_path).astype(np.float32)
|
||||
n_frames = len(actions)
|
||||
image_files = sorted(list(images_dir.glob("*.jpg")) + list(images_dir.glob("*.png")),
|
||||
key=lambda p: p.name)
|
||||
key=_natural_key)
|
||||
n_images = len(image_files)
|
||||
n = min(n_frames, n_images)
|
||||
for t in range(history_len, n):
|
||||
@@ -45,11 +50,18 @@ class ActionDataset(Dataset):
|
||||
self._action_mean = None
|
||||
self._action_std = None
|
||||
if normalize_actions:
|
||||
if action_stats_path and os.path.exists(action_stats_path):
|
||||
if action_stats_path:
|
||||
if not os.path.exists(action_stats_path):
|
||||
raise FileNotFoundError(
|
||||
f"指定的 action_stats_path 不存在: {action_stats_path}")
|
||||
stats = np.load(action_stats_path)
|
||||
self._action_mean = stats[0]
|
||||
self._action_std = stats[1]
|
||||
else:
|
||||
if not self.samples:
|
||||
raise ValueError(
|
||||
f"数据集为空(0 样本),无法计算归一化统计量。"
|
||||
f"请检查 data_dir={data_dir} 中的 episode 数据。")
|
||||
all_actions = np.stack([s[2] for s in self.samples])
|
||||
self._action_mean = all_actions.mean(axis=0)
|
||||
self._action_std = all_actions.std(axis=0) + 1e-8
|
||||
@@ -92,8 +104,11 @@ class DataRecorder:
|
||||
self._frame_idx = 0
|
||||
self._actions = []
|
||||
ep_idx = 0
|
||||
while (self.output_dir / f"episode_{ep_idx:04d}").exists():
|
||||
ep_idx += 1
|
||||
for d in self.output_dir.glob("episode_*"):
|
||||
try:
|
||||
ep_idx = max(ep_idx, int(d.name.split("_")[1]) + 1)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
self.episode_dir = self.output_dir / f"episode_{ep_idx:04d}"
|
||||
self.images_dir = self.episode_dir / "images"
|
||||
self.images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -36,6 +36,21 @@ def main():
|
||||
)
|
||||
print(f"ONNX model exported to: {args.output}")
|
||||
|
||||
# 可选:验证导出的 ONNX 模型一致性
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
import numpy as np
|
||||
sess = ort.InferenceSession(args.output)
|
||||
img_np = dummy_image.numpy()
|
||||
hist_np = dummy_history.numpy()
|
||||
ort_out = sess.run(None, {"image": img_np, "action_history": hist_np})[0]
|
||||
with torch.no_grad():
|
||||
torch_out = model(dummy_image, dummy_history).numpy()
|
||||
max_diff = np.abs(ort_out - torch_out).max()
|
||||
print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})")
|
||||
except ImportError:
|
||||
print("提示: 安装 onnxruntime 可验证导出模型一致性 (pip install onnxruntime)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+10
-1
@@ -30,7 +30,16 @@ class MobileNetEncoder(nn.Module):
|
||||
def __init__(self, feature_dim: int = 128):
|
||||
super().__init__()
|
||||
from torchvision.models import mobilenet_v2, MobileNet_V2_Weights
|
||||
backbone = mobilenet_v2(weights=MobileNet_V2_Weights.DEFAULT)
|
||||
try:
|
||||
backbone = mobilenet_v2(weights=MobileNet_V2_Weights.DEFAULT)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
"MobileNetV2 预训练权重下载失败,可能是网络受限。"
|
||||
"请在联网环境中预下载权重"
|
||||
"(运行 torchvision.models.mobilenet_v2(weights='IMAGENET1K_V1')),"
|
||||
"并将 ~/.cache/torch/hub/checkpoints/ 下的权重文件"
|
||||
"拷贝到目标机器同路径。"
|
||||
) from e
|
||||
self.features = backbone.features
|
||||
self.pool = nn.AdaptiveAvgPool2d(1)
|
||||
self.proj = nn.Linear(1280, feature_dim)
|
||||
|
||||
+14
-5
@@ -25,6 +25,9 @@ class ActionPredictorInference:
|
||||
self._model.load_state_dict(checkpoint["model_state_dict"])
|
||||
self._model.eval()
|
||||
|
||||
self.action_dim = self._data_cfg.action_dim
|
||||
self.history_len = self._data_cfg.history_len
|
||||
|
||||
self._transform = transforms.Compose([
|
||||
transforms.Resize((self._data_cfg.image_size, self._data_cfg.image_size)),
|
||||
transforms.ToTensor(),
|
||||
@@ -35,15 +38,20 @@ class ActionPredictorInference:
|
||||
self._action_std = None
|
||||
stats_path = os.path.join(os.path.dirname(os.path.abspath(checkpoint_path)),
|
||||
"action_stats.npy")
|
||||
try:
|
||||
if self._data_cfg.normalize_actions:
|
||||
if not os.path.exists(stats_path):
|
||||
raise FileNotFoundError(
|
||||
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
|
||||
stats = np.load(stats_path)
|
||||
self._action_mean = stats[0]
|
||||
self._action_std = stats[1]
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
|
||||
@torch.no_grad()
|
||||
def predict(self, image: np.ndarray, action_history: np.ndarray) -> np.ndarray:
|
||||
expected_shape = (self._data_cfg.history_len, self._data_cfg.action_dim)
|
||||
if action_history.shape != expected_shape:
|
||||
raise ValueError(
|
||||
f"action_history 形状应为 {expected_shape},实际为 {action_history.shape}")
|
||||
img = Image.fromarray(image) if isinstance(image, np.ndarray) else image
|
||||
img_tensor = self._transform(img).unsqueeze(0).to(self._device)
|
||||
history = torch.tensor(action_history, dtype=torch.float32)
|
||||
@@ -72,7 +80,8 @@ def main():
|
||||
parser.add_argument("--model", required=True, help="checkpoint path")
|
||||
parser.add_argument("--image", required=True, help="image file path")
|
||||
parser.add_argument("--action-history", required=True,
|
||||
help="colon-separated action vectors, e.g. '45,0,45,0:45,10,45,10'")
|
||||
help="冒号分隔各步,每步 action_dim 个值,共 history_len 步。"
|
||||
"e.g. '0,0,0,0:1,0,0,0' (action_dim=4, history_len=2)")
|
||||
parser.add_argument("--action-dim", type=int, default=None,
|
||||
help="override action dim (auto-detected from checkpoint if omitted)")
|
||||
parser.add_argument("--output-format", choices=["json", "raw"], default="json")
|
||||
@@ -80,7 +89,7 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
predictor = ActionPredictorInference(args.model, args.device)
|
||||
action_dim = args.action_dim or predictor._data_cfg.action_dim
|
||||
action_dim = args.action_dim or predictor.action_dim
|
||||
|
||||
image = Image.open(args.image).convert("RGB")
|
||||
history = parse_action_history(args.action_history, action_dim)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Core
|
||||
torch>=2.0
|
||||
torchvision>=0.15
|
||||
numpy>=1.24
|
||||
Pillow>=9.0
|
||||
torch>=2.0,<3.0
|
||||
torchvision>=0.15,<1.0
|
||||
numpy>=1.24,<3.0
|
||||
Pillow>=9.0,<12.0
|
||||
|
||||
# Training utilities
|
||||
tqdm>=4.60
|
||||
tqdm>=4.60,<5.0
|
||||
@@ -0,0 +1,219 @@
|
||||
"""actpred 模块单元测试
|
||||
|
||||
运行方式:
|
||||
cd actpred && python -m pytest test_actpred.py -v
|
||||
# 或
|
||||
cd actpred && python test_actpred.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
|
||||
from config import Config, DataConfig, ModelConfig, build_argparser, apply_overrides
|
||||
from dataset import _natural_key, ActionDataset, DataRecorder
|
||||
from model import ActionPredictor
|
||||
from predict import parse_action_history
|
||||
|
||||
|
||||
class TestNaturalKey(unittest.TestCase):
|
||||
"""测试自然排序键函数"""
|
||||
|
||||
def test_natural_order_no_padding(self):
|
||||
"""无前导零的文件名应按数字序排列"""
|
||||
files = [Path(f"{i}.jpg") for i in [3, 1, 10, 2, 20]]
|
||||
sorted_files = sorted(files, key=_natural_key)
|
||||
names = [f.name for f in sorted_files]
|
||||
self.assertEqual(names, ["1.jpg", "2.jpg", "3.jpg", "10.jpg", "20.jpg"])
|
||||
|
||||
def test_natural_order_with_padding(self):
|
||||
"""有前导零的文件名排序也应正确"""
|
||||
files = [Path(f"{i:04d}.jpg") for i in [3, 1, 10, 2, 20]]
|
||||
sorted_files = sorted(files, key=_natural_key)
|
||||
names = [f.name for f in sorted_files]
|
||||
self.assertEqual(names, ["0001.jpg", "0002.jpg", "0003.jpg", "0010.jpg", "0020.jpg"])
|
||||
|
||||
|
||||
class TestParseActionHistory(unittest.TestCase):
|
||||
"""测试动作历史解析函数"""
|
||||
|
||||
def test_single_step(self):
|
||||
history = parse_action_history("1,2,3,4", action_dim=4)
|
||||
self.assertEqual(history.shape, (1, 4))
|
||||
np.testing.assert_array_equal(history[0], [1, 2, 3, 4])
|
||||
|
||||
def test_multi_step(self):
|
||||
history = parse_action_history("1,0,0,0:0,1,0,0:0,0,1,0", action_dim=4)
|
||||
self.assertEqual(history.shape, (3, 4))
|
||||
|
||||
def test_wrong_dim_raises(self):
|
||||
with self.assertRaises(AssertionError):
|
||||
parse_action_history("1,2,3", action_dim=4)
|
||||
|
||||
|
||||
class TestActionPredictor(unittest.TestCase):
|
||||
"""测试模型前向传播"""
|
||||
|
||||
def test_forward_simple_mlp(self):
|
||||
data_cfg = DataConfig(image_size=64, action_dim=8, history_len=5)
|
||||
model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp")
|
||||
model = ActionPredictor(model_cfg, data_cfg)
|
||||
model.eval()
|
||||
|
||||
image = torch.randn(2, 3, 64, 64)
|
||||
history = torch.randn(2, 5, 8)
|
||||
with torch.no_grad():
|
||||
out = model(image, history)
|
||||
self.assertEqual(out.shape, (2, 8))
|
||||
|
||||
def test_forward_simple_gru(self):
|
||||
data_cfg = DataConfig(image_size=64, action_dim=8, history_len=5)
|
||||
model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="gru")
|
||||
model = ActionPredictor(model_cfg, data_cfg)
|
||||
model.eval()
|
||||
|
||||
image = torch.randn(2, 3, 64, 64)
|
||||
history = torch.randn(2, 5, 8)
|
||||
with torch.no_grad():
|
||||
out = model(image, history)
|
||||
self.assertEqual(out.shape, (2, 8))
|
||||
|
||||
def test_forward_dynamic_batch(self):
|
||||
data_cfg = DataConfig(image_size=32, action_dim=4, history_len=3)
|
||||
model_cfg = ModelConfig(cnn_backbone="simple", history_encoder="mlp")
|
||||
model = ActionPredictor(model_cfg, data_cfg)
|
||||
model.eval()
|
||||
|
||||
for batch_size in [1, 4, 8]:
|
||||
image = torch.randn(batch_size, 3, 32, 32)
|
||||
history = torch.randn(batch_size, 3, 4)
|
||||
with torch.no_grad():
|
||||
out = model(image, history)
|
||||
self.assertEqual(out.shape, (batch_size, 4))
|
||||
|
||||
|
||||
class TestDataRecorder(unittest.TestCase):
|
||||
"""测试数据录制器"""
|
||||
|
||||
def test_record_and_flush(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
recorder = DataRecorder(tmpdir, action_dim=4)
|
||||
for _ in range(10):
|
||||
img = np.random.randint(0, 255, (64, 64, 3), dtype=np.uint8)
|
||||
action = np.random.randn(4).astype(np.float32)
|
||||
recorder.record(img, action)
|
||||
episode_path = recorder.flush()
|
||||
|
||||
self.assertTrue(os.path.exists(os.path.join(episode_path, "actions.npy")))
|
||||
images_dir = os.path.join(episode_path, "images")
|
||||
self.assertTrue(os.path.isdir(images_dir))
|
||||
self.assertEqual(len(os.listdir(images_dir)), 10)
|
||||
|
||||
actions = np.load(os.path.join(episode_path, "actions.npy"))
|
||||
self.assertEqual(actions.shape, (10, 4))
|
||||
|
||||
def test_multiple_episodes_increment_index(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
r1 = DataRecorder(tmpdir, action_dim=4)
|
||||
r1.flush()
|
||||
r2 = DataRecorder(tmpdir, action_dim=4)
|
||||
r2.flush()
|
||||
|
||||
self.assertNotEqual(r1.episode_dir, r2.episode_dir)
|
||||
self.assertIn("episode_0001", str(r2.episode_dir))
|
||||
|
||||
|
||||
class TestActionDataset(unittest.TestCase):
|
||||
"""测试数据集加载"""
|
||||
|
||||
@staticmethod
|
||||
def _create_dummy_episode(base_dir, n_frames=10, action_dim=4, image_size=32):
|
||||
ep_dir = base_dir / "episode_0001"
|
||||
images_dir = ep_dir / "images"
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
actions = []
|
||||
for i in range(n_frames):
|
||||
img = Image.fromarray(
|
||||
np.random.randint(0, 255, (image_size, image_size, 3), dtype=np.uint8))
|
||||
img.save(images_dir / f"{i:04d}.jpg")
|
||||
actions.append(np.random.randn(action_dim).astype(np.float32))
|
||||
np.save(ep_dir / "actions.npy", np.array(actions))
|
||||
|
||||
def test_dataset_loading(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
self._create_dummy_episode(Path(tmpdir), n_frames=10, action_dim=4)
|
||||
ds = ActionDataset(tmpdir, history_len=3, action_dim=4, image_size=32)
|
||||
self.assertEqual(len(ds), 7) # 10 - 3 = 7 samples
|
||||
|
||||
sample = ds[0]
|
||||
self.assertIn("image", sample)
|
||||
self.assertIn("action_history", sample)
|
||||
self.assertIn("target", sample)
|
||||
self.assertEqual(sample["image"].shape, (3, 32, 32))
|
||||
self.assertEqual(sample["action_history"].shape, (3, 4))
|
||||
self.assertEqual(sample["target"].shape, (4,))
|
||||
|
||||
def test_episode_count(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
self._create_dummy_episode(Path(tmpdir))
|
||||
count = ActionDataset.get_episode_count(tmpdir)
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_empty_stats_raises(self):
|
||||
"""空数据集计算归一化统计量应报错"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with self.assertRaises(ValueError):
|
||||
ActionDataset(tmpdir, history_len=5, action_dim=4,
|
||||
normalize_actions=True)
|
||||
|
||||
def test_invalid_stats_path_raises(self):
|
||||
"""指定的 stats 路径不存在应报错"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
ActionDataset(tmpdir, history_len=3, action_dim=4,
|
||||
normalize_actions=True,
|
||||
action_stats_path="/nonexistent/path.npy")
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
"""测试配置和 CLI 参数"""
|
||||
|
||||
def test_default_config(self):
|
||||
cfg = Config()
|
||||
self.assertEqual(cfg.data.image_size, 64)
|
||||
self.assertEqual(cfg.data.action_dim, 8)
|
||||
self.assertEqual(cfg.data.history_len, 5)
|
||||
self.assertEqual(cfg.model.cnn_backbone, "simple")
|
||||
self.assertEqual(cfg.model.history_encoder, "mlp")
|
||||
self.assertEqual(cfg.train.epochs, 100)
|
||||
|
||||
def test_apply_overrides(self):
|
||||
parser = build_argparser("test")
|
||||
args = parser.parse_args(["--epochs", "50", "--lr", "0.01", "--backbone", "mobilenet"])
|
||||
cfg = apply_overrides(Config(), args)
|
||||
self.assertEqual(cfg.train.epochs, 50)
|
||||
self.assertEqual(cfg.train.lr, 0.01)
|
||||
self.assertEqual(cfg.model.cnn_backbone, "mobilenet")
|
||||
|
||||
def test_normalize_actions_flag(self):
|
||||
"""--normalize-actions 和 --no-normalize-actions 都应可用"""
|
||||
parser = build_argparser("test")
|
||||
args = parser.parse_args(["--normalize-actions"])
|
||||
self.assertTrue(args.normalize_actions)
|
||||
|
||||
parser = build_argparser("test")
|
||||
args = parser.parse_args(["--no-normalize-actions"])
|
||||
self.assertFalse(args.normalize_actions)
|
||||
|
||||
parser = build_argparser("test")
|
||||
args = parser.parse_args([])
|
||||
self.assertIsNone(args.normalize_actions)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -95,6 +95,10 @@ def main():
|
||||
cfg.data.action_stats_path, val_indices)
|
||||
|
||||
print(f"Samples: {len(train_ds)} train, {len(val_ds)} val")
|
||||
if len(train_ds) == 0:
|
||||
raise ValueError(
|
||||
f"训练集为空(0 样本)。请检查 data_dir={cfg.data.data_dir} "
|
||||
f"及 episode 数据完整性。")
|
||||
if len(val_ds) == 0:
|
||||
print("WARNING: validation set is empty (episodes too short/few); "
|
||||
"model selection is disabled, last epoch will be saved.")
|
||||
|
||||
Reference in new issue
Block a user