Files
Mujoco_WASM/training_server/rl/scripts/train.py
T
chenlin 438e56bcc8
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(training): release V0.9.1 避障训练与基础策略迁移
2026-09-08 10:50:13 +08:00

394 lines
15 KiB
Python

"""Script to train RL agent with RSL-RL."""
import json
import logging
import os
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Literal
# 训练器作为仓库内置子集直接从 scripts/ 启动,不要求额外执行 pip install -e。
TRAINER_ROOT = Path(__file__).resolve().parents[1]
SERVICE_ROOT = TRAINER_ROOT.parent
for source_root in (TRAINER_ROOT, SERVICE_ROOT):
if str(source_root) not in sys.path:
sys.path.insert(0, str(source_root))
import tyro
import warp as wp
# mjlab 1.2.0 的 GPU CUDA-graph 检查仍访问旧公开路径 wp.context;
# Warp 1.15 已把实现移到 warp._src.context,但保留了相同 runtime 契约。
if not hasattr(wp, "context"):
from warp._src import context as warp_context
wp.context = warp_context # type: ignore[attr-defined]
from mjlab.envs import ManagerBasedRlEnv, ManagerBasedRlEnvCfg
from mjlab.rl import MjlabOnPolicyRunner, RslRlBaseRunnerCfg, RslRlVecEnvWrapper
from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls
from mjlab.tasks.tracking.mdp import MotionCommandCfg
from mjlab.utils.gpu import select_gpus
from mjlab.utils.os import dump_yaml, get_checkpoint_path
from mjlab.utils.torch import configure_torch_backends
from mjlab.utils.wrappers import VideoRecorder
from task_config import OBSTACLE_TASK, deployment_metadata, validate_task_config
from tuning.schema import apply_reward_configuration, validate_configuration
@dataclass(frozen=True)
class TrainConfig:
env: ManagerBasedRlEnvCfg
agent: RslRlBaseRunnerCfg
motion_file: str | None = None
video: bool = False
video_length: int = 200
video_interval: int = 2000
enable_nan_guard: bool = False
torchrunx_log_dir: str | None = None
gpu_ids: list[int] | Literal["all"] | None = field(default_factory=lambda: [0])
output_dir: str | None = None
resume_checkpoint: str | None = None
pretrained_checkpoint: str | None = None
pretrained_upload_manifest: str | None = None
pretrained_onnx: str | None = None
pretrained_source_id: str | None = None
pretrained_allowed_roots: list[str] = field(default_factory=list)
reward_config: str | None = None
reward_config_json: str | None = None
task_config: str | None = None
@staticmethod
def from_task(task_id: str) -> "TrainConfig":
env_cfg = load_env_cfg(task_id)
agent_cfg = load_rl_cfg(task_id)
return TrainConfig(env=env_cfg, agent=agent_cfg)
def _load_reward_config(path: str | None, inline: str | None, task_id="Unitree-Go2-Flat") -> dict | None:
if path is not None and inline is not None:
raise ValueError("Use only one of reward_config and reward_config_json")
if inline is not None:
if len(inline.encode("utf-8")) > 64 * 1024:
raise ValueError("Reward configuration is larger than 64 KiB")
return validate_configuration(json.loads(inline), task_id)
if path is None:
return None
source = Path(path).expanduser().resolve(strict=True)
if source.stat().st_size > 64 * 1024:
raise ValueError("Reward configuration is larger than 64 KiB")
with source.open(encoding="utf-8") as stream:
return validate_configuration(json.load(stream), task_id)
def _load_task_config(task_id: str, path: str | None, seed: int) -> dict | None:
if path is None:
return validate_task_config(task_id, {}, seed)
source = Path(path).expanduser().resolve(strict=True)
if source.stat().st_size > 128 * 1024:
raise ValueError("Task configuration is larger than 128 KiB")
payload = json.loads(source.read_text(encoding="utf-8"))
allowed = {"terrainPreset", "terrainParams", "sensorCfg", "seed", "customTerrainBoxes"}
if not isinstance(payload, dict) or payload.keys() - allowed:
raise ValueError("Unknown task configuration fields")
if (
isinstance(payload.get("seed"), bool)
or not isinstance(payload.get("seed"), int)
or payload.get("seed") != seed
):
raise ValueError("Task configuration seed must equal the agent seed")
if payload.get("sensorCfg") is None:
payload.pop("sensorCfg", None)
return validate_task_config(task_id, payload, seed)
def _configure_task_and_rewards(task_id: str, cfg: TrainConfig):
custom = _load_task_config(task_id, cfg.task_config, cfg.agent.seed)
if custom is not None:
from src.tasks.obstacle_avoidance.env_cfg import apply_obstacle_configuration
from src.tasks.obstacle_avoidance.terrain import apply_terrain_configuration
if task_id == OBSTACLE_TASK:
apply_obstacle_configuration(cfg.env, custom)
else:
apply_terrain_configuration(cfg.env, custom)
deployment = deployment_metadata(task_id, custom, cfg.agent.seed)
reward_config = _load_reward_config(cfg.reward_config, cfg.reward_config_json, task_id)
if reward_config is not None:
apply_reward_configuration(cfg.env, reward_config, task_id)
if task_id == OBSTACLE_TASK:
deployment["navigation"]["speed"] = reward_config["params"]["target_velocity"]
deployment["sensorCfg"]["avoidanceWeight"] = reward_config["weights"]["avoidance_weight"]
return deployment, reward_config
def _load_pretrained(cfg: TrainConfig):
if cfg.pretrained_checkpoint is None:
if (cfg.pretrained_onnx is not None or cfg.pretrained_allowed_roots
or cfg.pretrained_source_id or cfg.pretrained_upload_manifest):
raise ValueError("Pretrained options require --pretrained-checkpoint (.pt), not ONNX alone")
return None
if cfg.resume_checkpoint is not None or cfg.agent.resume:
raise ValueError("Pretrained warm-start and resume are mutually exclusive")
from pretrained import read_pretrained_source
options = {"onnx_path": cfg.pretrained_onnx}
if cfg.pretrained_upload_manifest is not None:
if cfg.pretrained_onnx is not None:
raise ValueError("Uploaded actor cannot use adjacent ONNX sidecars")
from pretrained_upload import read_uploaded_source
read_pretrained_source = read_uploaded_source
options = {"manifest_path": cfg.pretrained_upload_manifest}
source = read_pretrained_source(
cfg.pretrained_checkpoint,
allowed_roots=cfg.pretrained_allowed_roots,
**options,
target_env=asdict(cfg.env),
target_agent=asdict(cfg.agent),
)
if cfg.pretrained_source_id is not None and source.manifest["source_id"] != cfg.pretrained_source_id:
raise ValueError("基础策略SHA身份变化,拒绝初始化")
return source
def run_train(task_id: str, cfg: TrainConfig, log_dir: Path) -> None:
deployment, reward_config = _configure_task_and_rewards(task_id, cfg)
# Validate source identity/semantics before allocating a simulation or optimizer.
pretrained = _load_pretrained(cfg)
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", "")
if cuda_visible == "":
device = "cpu"
seed = cfg.agent.seed
rank = 0
else:
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
rank = int(os.environ.get("RANK", "0"))
# Set EGL device to match the CUDA device.
os.environ["MUJOCO_EGL_DEVICE_ID"] = str(local_rank)
device = f"cuda:{local_rank}"
# Set seed to have diversity in different processes.
seed = cfg.agent.seed + local_rank
configure_torch_backends()
cfg.agent.seed = seed
cfg.env.seed = seed
print(f"[INFO] Training with: device={device}, seed={seed}, rank={rank}")
# Check if this is a tracking task by checking for motion command.
is_tracking_task = "motion" in cfg.env.commands and isinstance(
cfg.env.commands["motion"], MotionCommandCfg
)
if is_tracking_task:
if not cfg.motion_file:
raise ValueError("For tracking tasks, --motion-file must be set ...")
motion_path = Path(cfg.motion_file).expanduser().resolve()
if not motion_path.exists():
raise FileNotFoundError(f"Motion file not found: {motion_path}")
motion_cmd = cfg.env.commands["motion"]
assert isinstance(motion_cmd, MotionCommandCfg)
motion_cmd.motion_file = str(motion_path)
print(f"[INFO] Using motion file: {motion_cmd.motion_file}")
# Check if motion_file is already set (e.g., via CLI --env.commands.motion.motion-file).
if motion_cmd.motion_file and Path(motion_cmd.motion_file).exists():
print(f"[INFO] Using local motion file: {motion_cmd.motion_file}")
# Enable NaN guard if requested.
if cfg.enable_nan_guard:
cfg.env.sim.nan_guard.enabled = True
print(f"[INFO] NaN guard enabled, output dir: {cfg.env.sim.nan_guard.output_dir}")
if rank == 0:
print(f"[INFO] Logging experiment in directory: {log_dir}")
env = ManagerBasedRlEnv(
cfg=cfg.env, device=device, render_mode="rgb_array" if cfg.video else None
)
if pretrained is not None:
from pretrained import validate_runtime_contract
try:
validate_runtime_contract(env)
except Exception:
env.close()
raise
# The ONNX runner exports this exact snapshot beside and inside policy.onnx.
env.platform_deployment = deployment
log_root_path = log_dir.parent # Go up from specific run dir to experiment dir.
resume_path: Path | None = None
explicit_resume = cfg.resume_checkpoint is not None
if explicit_resume:
resume_path = Path(cfg.resume_checkpoint).expanduser().resolve(strict=True)
elif cfg.agent.resume:
# Load checkpoint from local filesystem.
resume_path = get_checkpoint_path(
log_root_path, cfg.agent.load_run, cfg.agent.load_checkpoint
)
# Only record videos on rank 0 to avoid multiple workers writing to the same files.
if cfg.video and rank == 0:
env = VideoRecorder(
env,
video_folder=Path(log_dir) / "videos" / "train",
step_trigger=lambda step: step % cfg.video_interval == 0,
video_length=cfg.video_length,
disable_logger=True,
)
print("[INFO] Recording videos during training.")
env = RslRlVecEnvWrapper(env, clip_actions=cfg.agent.clip_actions)
agent_cfg = asdict(cfg.agent)
env_cfg = asdict(cfg.env)
runner_cls = load_runner_cls(task_id)
if runner_cls is None:
runner_cls = MjlabOnPolicyRunner
runner_kwargs = {}
runner = runner_cls(env, agent_cfg, str(log_dir), device, **runner_kwargs)
runner.add_git_repo_to_log(__file__)
if pretrained is not None:
from pretrained import initialize_runner
initialization = initialize_runner(runner, pretrained)
env.unwrapped.platform_initialization = initialization
if rank == 0:
(log_dir / "initialization.json").write_text(
json.dumps(initialization, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"[INFO] Warm-start actor from source {initialization['source_id']}; fresh critic/optimizer, iteration=0")
if resume_path is not None:
print(f"[INFO]: Loading model checkpoint from: {resume_path}")
runner.load(str(resume_path), map_location=device)
# Promotion copies the origin manifest, never the original actor weights.
origin_path = resume_path.parent / "initialization.json"
if origin_path.is_file():
if origin_path.stat().st_size > 64 * 1024:
raise ValueError("Initialization provenance exceeds 64 KiB")
origin = json.loads(origin_path.read_text(encoding="utf-8"))
if not isinstance(origin, dict) or origin.get("mode") != "pretrained-warm-start":
raise ValueError("Invalid initialization provenance")
env.unwrapped.platform_initialization = origin
if rank == 0:
(log_dir / "initialization.json").write_text(json.dumps(origin, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if explicit_resume:
# RSL-RL stores the last completed zero-based iteration and otherwise
# repeats it after load. Explicit tuning promotion uses an absolute target.
runner.current_learning_iteration += 1
# Only write config files from rank 0 to avoid race conditions.
if rank == 0:
dump_yaml(log_dir / "params" / "env.yaml", env_cfg)
dump_yaml(log_dir / "params" / "agent.yaml", agent_cfg)
if reward_config is not None:
reward_snapshot = log_dir / "params" / "reward_config.json"
reward_snapshot.parent.mkdir(parents=True, exist_ok=True)
reward_snapshot.write_text(
json.dumps(reward_config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
iterations = cfg.agent.max_iterations
if explicit_resume:
iterations = max(0, cfg.agent.max_iterations - runner.current_learning_iteration)
print(
f"[INFO] Learning target: current={runner.current_learning_iteration}, "
f"additional={iterations}, target={cfg.agent.max_iterations}",
flush=True,
)
runner.learn(num_learning_iterations=iterations, init_at_random_ep_len=True)
env.close()
def launch_training(task_id: str, args: TrainConfig | None = None):
args = args or TrainConfig.from_task(task_id)
# Create log directory once before launching workers.
if args.output_dir:
log_dir = Path(args.output_dir).expanduser().resolve()
else:
log_root_path = (Path("logs") / "rsl_rl" / args.agent.experiment_name).resolve()
log_dir_name = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if args.agent.run_name:
log_dir_name += f"_{args.agent.run_name}"
log_dir = log_root_path / log_dir_name
log_dir.mkdir(parents=True, exist_ok=True)
# Select GPUs based on CUDA_VISIBLE_DEVICES and user specification.
selected_gpus, num_gpus = select_gpus(args.gpu_ids)
# Set environment variables for all modes.
if selected_gpus is None:
os.environ["CUDA_VISIBLE_DEVICES"] = ""
else:
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, selected_gpus))
os.environ["MUJOCO_GL"] = "egl"
if num_gpus <= 1:
# CPU or single GPU: run directly without torchrunx.
run_train(task_id, args, log_dir)
else:
# Multi-GPU: use torchrunx.
import torchrunx
# torchrunx redirects stdout to logging.
logging.basicConfig(level=logging.INFO)
# Configure torchrunx logging directory.
# Priority: 1) existing env var, 2) user flag, 3) default to {log_dir}/torchrunx.
if "TORCHRUNX_LOG_DIR" not in os.environ:
if args.torchrunx_log_dir is not None:
# User specified a value via flag (could be "" to disable).
os.environ["TORCHRUNX_LOG_DIR"] = args.torchrunx_log_dir
else:
# Default: put logs in training directory.
os.environ["TORCHRUNX_LOG_DIR"] = str(log_dir / "torchrunx")
print(f"[INFO] Launching training with {num_gpus} GPUs", flush=True)
torchrunx.Launcher(
hostnames=["localhost"],
workers_per_host=num_gpus,
backend=None, # Let rsl_rl handle process group initialization.
copy_env_vars=torchrunx.DEFAULT_ENV_VARS_FOR_COPY + ("MUJOCO*",),
).run(run_train, task_id, args, log_dir)
def main():
# Parse first argument to choose the task.
# Import tasks to populate the registry.
import mjlab.tasks # noqa: F401
import src.tasks # noqa: F401
all_tasks = list_tasks()
chosen_task, remaining_args = tyro.cli(
tyro.extras.literal_type_from_choices(all_tasks),
add_help=False,
return_unknown_args=True,
config=mjlab.TYRO_FLAGS,
)
args = tyro.cli(
TrainConfig,
args=remaining_args,
default=TrainConfig.from_task(chosen_task),
prog=sys.argv[0] + f" {chosen_task}",
config=mjlab.TYRO_FLAGS,
)
del remaining_args
launch_training(task_id=chosen_task, args=args)
if __name__ == "__main__":
main()