Merge branch 'dev' into feat/message_passing
This commit is contained in:
commit
2594c53d49
13 changed files with 427 additions and 5 deletions
8
configs/evaluation/default.yaml
Normal file
8
configs/evaluation/default.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Default Evaluation Configuration
|
||||||
|
# Settings used for checkpoint evaluation during training.
|
||||||
|
|
||||||
|
evaluate_checkpoints: false
|
||||||
|
# Max number of control steps during evaluation rollout.
|
||||||
|
eval_max_steps: 5000
|
||||||
|
# Seed for deterministic evaluation reset.
|
||||||
|
eval_seed: 0
|
||||||
|
|
@ -10,4 +10,4 @@ save_checkpoints: true
|
||||||
checkpoint_frequency: 100
|
checkpoint_frequency: 100
|
||||||
upload_final_model: false
|
upload_final_model: false
|
||||||
upload_checkpoints: false
|
upload_checkpoints: false
|
||||||
hf_entity: ""
|
hf_entity: ""
|
||||||
|
|
@ -6,6 +6,7 @@ defaults:
|
||||||
- brittle_star_config
|
- brittle_star_config
|
||||||
- experiment: base
|
- experiment: base
|
||||||
- logging: default
|
- logging: default
|
||||||
|
- evaluation: default
|
||||||
- ppo: default
|
- ppo: default
|
||||||
- architecture: centralized
|
- architecture: centralized
|
||||||
- morphology: 5_arms_full
|
- morphology: 5_arms_full
|
||||||
|
|
|
||||||
24
src/brittle_star_project/configs/config_evaluation.py
Normal file
24
src/brittle_star_project/configs/config_evaluation.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvaluationConfig:
|
||||||
|
"""Evaluation settings.
|
||||||
|
|
||||||
|
Currently used for synchronous checkpoint evaluation during training.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# When enabled, each saved checkpoint is evaluated headlessly and the results
|
||||||
|
# are appended to a CSV in the run's metrics/ folder.
|
||||||
|
evaluate_checkpoints: bool = False
|
||||||
|
eval_max_steps: int = 5000
|
||||||
|
eval_seed: int = 0
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.evaluate_checkpoints and self.eval_max_steps <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Configuration Error: 'eval_max_steps' must be > 0 when "
|
||||||
|
"'evaluate_checkpoints' is enabled."
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ from dataclasses import dataclass, field
|
||||||
|
|
||||||
from experiment_logger.config_logger import LoggingConfig
|
from experiment_logger.config_logger import LoggingConfig
|
||||||
from brittle_star_project.configs.config_experiment import ExperimentConfig
|
from brittle_star_project.configs.config_experiment import ExperimentConfig
|
||||||
|
from brittle_star_project.configs.config_evaluation import EvaluationConfig
|
||||||
from brittle_star_project.configs.config_ppo import PPOConfig
|
from brittle_star_project.configs.config_ppo import PPOConfig
|
||||||
from brittle_star_project.configs.config_architecture import ArchitectureConfig
|
from brittle_star_project.configs.config_architecture import ArchitectureConfig
|
||||||
from brittle_star_project.configs.config_simulation import SimulationSettings
|
from brittle_star_project.configs.config_simulation import SimulationSettings
|
||||||
|
|
@ -23,6 +24,7 @@ class BrittleStarConfig:
|
||||||
|
|
||||||
experiment: ExperimentConfig = field(default_factory=ExperimentConfig)
|
experiment: ExperimentConfig = field(default_factory=ExperimentConfig)
|
||||||
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
||||||
|
evaluation: EvaluationConfig = field(default_factory=EvaluationConfig)
|
||||||
ppo: PPOConfig = field(default_factory=PPOConfig)
|
ppo: PPOConfig = field(default_factory=PPOConfig)
|
||||||
# This field is polymorphic; defaults to the base class to allow subclasses
|
# This field is polymorphic; defaults to the base class to allow subclasses
|
||||||
# (CentralizedConfig, DecentralizedConfig) to be merged in via Hydra.
|
# (CentralizedConfig, DecentralizedConfig) to be merged in via Hydra.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from hydra.core.config_store import ConfigStore
|
||||||
|
|
||||||
from experiment_logger.config_logger import LoggingConfig
|
from experiment_logger.config_logger import LoggingConfig
|
||||||
from brittle_star_project.configs.config_experiment import ExperimentConfig
|
from brittle_star_project.configs.config_experiment import ExperimentConfig
|
||||||
|
from brittle_star_project.configs.config_evaluation import EvaluationConfig
|
||||||
from brittle_star_project.configs.config_ppo import PPOConfig
|
from brittle_star_project.configs.config_ppo import PPOConfig
|
||||||
from brittle_star_project.configs.config_architecture import (
|
from brittle_star_project.configs.config_architecture import (
|
||||||
CentralizedConfig,
|
CentralizedConfig,
|
||||||
|
|
@ -32,6 +33,7 @@ def register_configs() -> None:
|
||||||
# Sub-config groups — each group corresponds to a configs/ subdirectory.
|
# Sub-config groups — each group corresponds to a configs/ subdirectory.
|
||||||
cs.store(group="experiment", name="base_experiment", node=ExperimentConfig)
|
cs.store(group="experiment", name="base_experiment", node=ExperimentConfig)
|
||||||
cs.store(group="logging", name="base_logging", node=LoggingConfig)
|
cs.store(group="logging", name="base_logging", node=LoggingConfig)
|
||||||
|
cs.store(group="evaluation", name="base_evaluation", node=EvaluationConfig)
|
||||||
cs.store(group="ppo", name="base_ppo", node=PPOConfig)
|
cs.store(group="ppo", name="base_ppo", node=PPOConfig)
|
||||||
|
|
||||||
# Architecture variants — swap via CLI: architecture=decentralized
|
# Architecture variants — swap via CLI: architecture=decentralized
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,35 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .checkpoint import load_metadata, load_params, metadata_to_configs, TrainingConfig
|
from .checkpoint import load_metadata, load_params, metadata_to_configs, TrainingConfig
|
||||||
|
from .evaluate_mjx import (
|
||||||
|
CheckpointEvalResult,
|
||||||
|
append_checkpoint_eval_row,
|
||||||
|
build_eval_rollout_fn,
|
||||||
|
evaluate_checkpoint_mjx,
|
||||||
|
)
|
||||||
from .policy import PolicyAgent, ControlPolicy
|
from .policy import PolicyAgent, ControlPolicy
|
||||||
from .rollout import rollout_headless, rollout_viewer, EpisodeResult
|
from .rollout import rollout_headless, rollout_viewer, EpisodeResult
|
||||||
from .video import record_episode, create_evaluation_dir, save_evaluation_metadata
|
from .video import record_episode, create_evaluation_dir, save_evaluation_metadata
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
# checkpoint loading
|
||||||
"load_metadata",
|
"load_metadata",
|
||||||
"load_params",
|
"load_params",
|
||||||
"metadata_to_configs",
|
"metadata_to_configs",
|
||||||
"TrainingConfig",
|
"TrainingConfig",
|
||||||
|
# MJX evaluation
|
||||||
|
"CheckpointEvalResult",
|
||||||
|
"append_checkpoint_eval_row",
|
||||||
|
"build_eval_rollout_fn",
|
||||||
|
"evaluate_checkpoint_mjx",
|
||||||
|
# policy
|
||||||
"PolicyAgent",
|
"PolicyAgent",
|
||||||
"ControlPolicy",
|
"ControlPolicy",
|
||||||
|
# rollout
|
||||||
"rollout_headless",
|
"rollout_headless",
|
||||||
"rollout_viewer",
|
"rollout_viewer",
|
||||||
"EpisodeResult",
|
"EpisodeResult",
|
||||||
|
# video
|
||||||
"record_episode",
|
"record_episode",
|
||||||
"create_evaluation_dir",
|
"create_evaluation_dir",
|
||||||
"save_evaluation_metadata",
|
"save_evaluation_metadata",
|
||||||
|
|
|
||||||
250
src/brittle_star_project/evaluation/evaluate_mjx.py
Normal file
250
src/brittle_star_project/evaluation/evaluate_mjx.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
"""MJX-based headless checkpoint evaluation.
|
||||||
|
|
||||||
|
This module provides a fast, JIT-compiled evaluation path using the MJX
|
||||||
|
(JAX-accelerated MuJoCo) backend. It is intended for evaluating checkpoints
|
||||||
|
*during* or *after* a training run, where the environment and policy are
|
||||||
|
already fully initialised.
|
||||||
|
|
||||||
|
The key functions are:
|
||||||
|
|
||||||
|
- `build_eval_rollout_fn` — builds and JIT-compiles a single-episode rollout function from the
|
||||||
|
training environment and policy components.
|
||||||
|
- `evaluate_checkpoint_mjx` — runs that function for a given set of parameters and returns a typed
|
||||||
|
`CheckpointEvalResult`.
|
||||||
|
- `append_checkpoint_eval_row` — persists the result to the run's
|
||||||
|
``metrics/checkpoint_evaluation.csv``, migrating old schemas automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import jax
|
||||||
|
import jax.numpy as jnp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckpointEvalResult:
|
||||||
|
"""Structured result from a single MJX checkpoint evaluation episode."""
|
||||||
|
|
||||||
|
steps: int
|
||||||
|
"""Number of control steps taken (≤ max_steps)."""
|
||||||
|
|
||||||
|
reached_target: bool
|
||||||
|
"""Whether the robot reached the target (terminated) before max_steps."""
|
||||||
|
|
||||||
|
eval_return: float
|
||||||
|
"""Accumulated shaped reward over the episode."""
|
||||||
|
|
||||||
|
final_xy_dist: float
|
||||||
|
"""XY distance to target at episode end. 0.0 when ``reached_target`` is True."""
|
||||||
|
|
||||||
|
initial_xy_dist: float
|
||||||
|
"""XY distance to target at episode start."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_eval_rollout_fn(
|
||||||
|
*,
|
||||||
|
env: Any,
|
||||||
|
obs_processor: Callable,
|
||||||
|
sensor_apply: Callable,
|
||||||
|
actor_apply: Callable,
|
||||||
|
action_low: jnp.ndarray,
|
||||||
|
action_high: jnp.ndarray,
|
||||||
|
reward_fn: Callable,
|
||||||
|
) -> Callable:
|
||||||
|
"""Build and JIT-compile a single-episode MJX evaluation rollout.
|
||||||
|
|
||||||
|
All outputs are JAX arrays. Convert to Python scalars before logging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: The training environment wrapper. Must expose ``env.raw`` with
|
||||||
|
``reset`` and ``step`` methods compatible with ``jax.vmap``.
|
||||||
|
obs_processor: Observation normalisation / padding callable, as
|
||||||
|
returned by ``create_obs_processor``.
|
||||||
|
sensor_apply: The sensor network's ``apply`` method (JIT-compiled).
|
||||||
|
actor_apply: The actor network's ``apply`` method (JIT-compiled).
|
||||||
|
action_low: Per-joint action lower bound (JAX array, shape ``(action_dim,)``).
|
||||||
|
action_high: Per-joint action upper bound (JAX array, shape ``(action_dim,)``).
|
||||||
|
reward_fn: Shaped reward function with signature
|
||||||
|
``reward_fn(env_state, next_env_state) -> jnp.ndarray``.
|
||||||
|
Typically the module-level ``reward_fn`` from ``PPOTrainer``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A JIT-compiled callable that runs one deterministic evaluation episode.
|
||||||
|
"""
|
||||||
|
# vmap over a batch of 1 so the MJX API is satisfied without any
|
||||||
|
# extra bookkeeping in the caller.
|
||||||
|
reset_1 = jax.vmap(env.raw.reset)
|
||||||
|
step_1 = jax.vmap(env.raw.step)
|
||||||
|
|
||||||
|
def _eval_rollout(params: dict, seed: int, max_steps: int):
|
||||||
|
rng = jax.random.PRNGKey(seed)
|
||||||
|
rngs = jnp.asarray(jax.random.split(rng, 1))
|
||||||
|
state = reset_1(rng=rngs)
|
||||||
|
|
||||||
|
initial_xy_dist = jnp.squeeze(state.observations["xy_distance_to_target"])
|
||||||
|
|
||||||
|
t0 = jnp.asarray(0, dtype=jnp.int32)
|
||||||
|
done0 = jnp.squeeze(state.terminated | state.truncated)
|
||||||
|
return0 = jnp.asarray(0.0, dtype=jnp.float32)
|
||||||
|
|
||||||
|
def cond(carry):
|
||||||
|
t, _state, done, _return_ = carry
|
||||||
|
return jnp.logical_and(t < max_steps, jnp.logical_not(done))
|
||||||
|
|
||||||
|
def body(carry):
|
||||||
|
t, state, _done, return_ = carry
|
||||||
|
|
||||||
|
obs = obs_processor(state.observations)
|
||||||
|
hidden = sensor_apply(params["sensor_params"], obs)
|
||||||
|
mean, _log_std = actor_apply(params["actor_params"], hidden)
|
||||||
|
|
||||||
|
# Deterministic action: use the actor mean, no exploration noise.
|
||||||
|
action = jnp.clip(mean, action_low, action_high)
|
||||||
|
next_state = step_1(state=state, action=action)
|
||||||
|
|
||||||
|
shaped_reward = reward_fn(state, next_state)
|
||||||
|
return_ = return_ + jnp.squeeze(shaped_reward)
|
||||||
|
|
||||||
|
done_next = jnp.squeeze(next_state.terminated | next_state.truncated)
|
||||||
|
return (t + 1, next_state, done_next, return_)
|
||||||
|
|
||||||
|
t, final_state, _done, return_ = jax.lax.while_loop(cond, body, (t0, state, done0, return0))
|
||||||
|
|
||||||
|
reached_target = jnp.squeeze(final_state.terminated)
|
||||||
|
final_xy_dist_raw = jnp.squeeze(final_state.observations["xy_distance_to_target"])
|
||||||
|
# Clamp to 0 when the target was reached so downstream consumers
|
||||||
|
# don't have to special-case "terminated" themselves.
|
||||||
|
final_xy_dist = jnp.where(reached_target, 0.0, final_xy_dist_raw)
|
||||||
|
|
||||||
|
return t, reached_target, return_, final_xy_dist, initial_xy_dist
|
||||||
|
|
||||||
|
return jax.jit(_eval_rollout)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_checkpoint_mjx(
|
||||||
|
eval_fn: Callable,
|
||||||
|
params: dict,
|
||||||
|
*,
|
||||||
|
seed: int,
|
||||||
|
max_steps: int,
|
||||||
|
) -> CheckpointEvalResult:
|
||||||
|
"""Run one deterministic evaluation episode and return typed metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
eval_fn: A JIT-compiled function as returned by `build_eval_rollout_fn`.
|
||||||
|
params: Agent parameter dict (e.g. ``agent_state.params``).
|
||||||
|
seed: Random seed for environment reset (controls target placement).
|
||||||
|
max_steps: Maximum number of control steps before the episode is cut off.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A `CheckpointEvalResult` with all JAX arrays converted to
|
||||||
|
plain Python scalars.
|
||||||
|
"""
|
||||||
|
steps, reached, eval_return, final_xy_dist, initial_xy_dist = eval_fn(params, seed, max_steps)
|
||||||
|
return CheckpointEvalResult(
|
||||||
|
steps=int(steps),
|
||||||
|
reached_target=bool(reached),
|
||||||
|
eval_return=float(eval_return),
|
||||||
|
final_xy_dist=float(final_xy_dist),
|
||||||
|
initial_xy_dist=float(initial_xy_dist),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_FIELDNAMES = [
|
||||||
|
"checkpoint",
|
||||||
|
"trained_timesteps",
|
||||||
|
"eval_steps",
|
||||||
|
"eval_return",
|
||||||
|
"final_xy_dist",
|
||||||
|
"initial_xy_dist",
|
||||||
|
"reached_target",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_csv_if_needed(csv_path: Path) -> None:
|
||||||
|
"""Rewrite the CSV with the canonical field names if the schema changed.
|
||||||
|
|
||||||
|
Best-effort: any exception is silently swallowed so that a schema mismatch
|
||||||
|
never causes a training crash.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(csv_path, "r", newline="") as f:
|
||||||
|
header = next(csv.reader(f), None)
|
||||||
|
|
||||||
|
if header is None or list(header) == _FIELDNAMES:
|
||||||
|
return # Nothing to migrate.
|
||||||
|
|
||||||
|
migrated_rows: list[dict[str, Any]] = []
|
||||||
|
with open(csv_path, "r", newline="") as f:
|
||||||
|
for row in csv.DictReader(f):
|
||||||
|
migrated_rows.append(
|
||||||
|
{
|
||||||
|
"checkpoint": row.get("checkpoint", row.get("iteration")),
|
||||||
|
"trained_timesteps": row.get("trained_timesteps"),
|
||||||
|
"eval_steps": row.get("eval_steps", row.get("steps_to_target")),
|
||||||
|
"eval_return": row.get("eval_return"),
|
||||||
|
"final_xy_dist": row.get("final_xy_dist"),
|
||||||
|
"initial_xy_dist": row.get("initial_xy_dist"),
|
||||||
|
"reached_target": row.get("reached_target"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(csv_path, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=_FIELDNAMES)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(migrated_rows)
|
||||||
|
except Exception:
|
||||||
|
pass # Never crash training on a migration issue.
|
||||||
|
|
||||||
|
|
||||||
|
def append_checkpoint_eval_row(
|
||||||
|
run_dir: str | Path,
|
||||||
|
*,
|
||||||
|
iteration: int,
|
||||||
|
trained_timesteps: int,
|
||||||
|
result: CheckpointEvalResult,
|
||||||
|
) -> Path:
|
||||||
|
"""Append one evaluation row to ``<run_dir>/metrics/checkpoint_evaluation.csv``.
|
||||||
|
|
||||||
|
Creates the file (including the ``metrics/`` directory) if it does not yet
|
||||||
|
exist. Migrates the file to the current schema if the header has changed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run_dir: Root directory of the training run (Hydra's output dir).
|
||||||
|
iteration: Training iteration number, used as the checkpoint identifier.
|
||||||
|
trained_timesteps: Total environment steps taken at this checkpoint.
|
||||||
|
result: Evaluation result as returned by `evaluate_checkpoint_mjx`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the CSV file (useful for W&B sync).
|
||||||
|
"""
|
||||||
|
metrics_dir = Path(run_dir) / "metrics"
|
||||||
|
metrics_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
csv_path = metrics_dir / "checkpoint_evaluation.csv"
|
||||||
|
|
||||||
|
if csv_path.exists():
|
||||||
|
_migrate_csv_if_needed(csv_path)
|
||||||
|
|
||||||
|
file_exists = csv_path.exists()
|
||||||
|
with open(csv_path, "a", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=_FIELDNAMES)
|
||||||
|
if not file_exists:
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"checkpoint": int(iteration),
|
||||||
|
"trained_timesteps": int(trained_timesteps),
|
||||||
|
"eval_steps": result.steps,
|
||||||
|
"eval_return": result.eval_return,
|
||||||
|
"final_xy_dist": result.final_xy_dist,
|
||||||
|
"initial_xy_dist": result.initial_xy_dist,
|
||||||
|
"reached_target": result.reached_target,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return csv_path
|
||||||
|
|
@ -62,6 +62,28 @@ class PolicyAgent:
|
||||||
}
|
}
|
||||||
self._obs_processor = obs_processor
|
self._obs_processor = obs_processor
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_params(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
sensor_params: Any,
|
||||||
|
actor_params: Any,
|
||||||
|
action_dim: int,
|
||||||
|
obs_processor: Any,
|
||||||
|
) -> "PolicyAgent":
|
||||||
|
"""Construct a PolicyAgent directly from in-memory parameters."""
|
||||||
|
return cls(
|
||||||
|
sensor_params=sensor_params,
|
||||||
|
actor_params=actor_params,
|
||||||
|
action_dim=action_dim,
|
||||||
|
obs_processor=obs_processor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_params(self, *, sensor_params: Any, actor_params: Any) -> None:
|
||||||
|
"""Update parameters for evaluation without rebuilding the model."""
|
||||||
|
self._params["sensor_params"] = sensor_params
|
||||||
|
self._params["actor_params"] = actor_params
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_checkpoint(
|
def from_checkpoint(
|
||||||
cls,
|
cls,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,12 @@ from brittle_star_project.configs.main_config import BrittleStarConfig
|
||||||
from brittle_star_project.dataclasses import EpisodeStatistics
|
from brittle_star_project.dataclasses import EpisodeStatistics
|
||||||
from brittle_star_project.environment.BrittleStarJaxEnvWrapper import BrittleStarJaxEnvWrapper
|
from brittle_star_project.environment.BrittleStarJaxEnvWrapper import BrittleStarJaxEnvWrapper
|
||||||
from brittle_star_project.environment.obs_processing import create_obs_processor
|
from brittle_star_project.environment.obs_processing import create_obs_processor
|
||||||
from brittle_star_project.MLPs import (
|
from brittle_star_project.evaluation.evaluate_mjx import (
|
||||||
|
append_checkpoint_eval_row,
|
||||||
|
build_eval_rollout_fn,
|
||||||
|
evaluate_checkpoint_mjx,
|
||||||
|
)
|
||||||
|
from brittle_star_project.MLPs.mlps import (
|
||||||
Actor,
|
Actor,
|
||||||
AgentParams,
|
AgentParams,
|
||||||
GenericDenseLayersWithActivation,
|
GenericDenseLayersWithActivation,
|
||||||
|
|
@ -31,6 +36,10 @@ from brittle_star_project.ppo import PPO
|
||||||
from brittle_star_project.environment import MorphMode
|
from brittle_star_project.environment import MorphMode
|
||||||
from brittle_star_project.utils import logged_jit
|
from brittle_star_project.utils import logged_jit
|
||||||
|
|
||||||
|
from brittle_star_project.environment.env_types import Backend
|
||||||
|
|
||||||
|
# TODO: clip scaled reward?
|
||||||
|
|
||||||
|
|
||||||
@logged_jit
|
@logged_jit
|
||||||
def _clip_action(action: jnp.ndarray, low: jnp.ndarray, high: jnp.ndarray) -> jnp.ndarray:
|
def _clip_action(action: jnp.ndarray, low: jnp.ndarray, high: jnp.ndarray) -> jnp.ndarray:
|
||||||
|
|
@ -176,8 +185,13 @@ def _step_once(
|
||||||
), storage
|
), storage
|
||||||
|
|
||||||
|
|
||||||
def _reward_fn(env_state, next_env_state):
|
def reward_fn(env_state, next_env_state):
|
||||||
# if delta distance positive ==> brittle star walking away from target
|
"""Shaped reward used during training and checkpoint evaluation.
|
||||||
|
|
||||||
|
Public so that ``evaluation.evaluate_mjx`` can import it and produce
|
||||||
|
metrics that are directly comparable to training-time returns.
|
||||||
|
"""
|
||||||
|
# Positive delta_distance means the brittle star is moving *away* from target.
|
||||||
delta_distance = (
|
delta_distance = (
|
||||||
next_env_state.observations["xy_distance_to_target"]
|
next_env_state.observations["xy_distance_to_target"]
|
||||||
- env_state.observations["xy_distance_to_target"]
|
- env_state.observations["xy_distance_to_target"]
|
||||||
|
|
@ -204,7 +218,7 @@ def _step_env_wrapped(
|
||||||
):
|
):
|
||||||
next_env_state_pre_reset = env_step_fn(env_state, action)
|
next_env_state_pre_reset = env_step_fn(env_state, action)
|
||||||
|
|
||||||
reward = _reward_fn(env_state, next_env_state_pre_reset)
|
reward = reward_fn(env_state, next_env_state_pre_reset)
|
||||||
terminated = next_env_state_pre_reset.terminated
|
terminated = next_env_state_pre_reset.terminated
|
||||||
truncated = next_env_state_pre_reset.truncated
|
truncated = next_env_state_pre_reset.truncated
|
||||||
done = terminated | truncated
|
done = terminated | truncated
|
||||||
|
|
@ -416,6 +430,7 @@ class PPOTrainer:
|
||||||
self.ppo = cfg.ppo
|
self.ppo = cfg.ppo
|
||||||
self.experiment = cfg.experiment
|
self.experiment = cfg.experiment
|
||||||
self.logging_cfg = cfg.logging
|
self.logging_cfg = cfg.logging
|
||||||
|
self.evaluation_cfg = cfg.evaluation
|
||||||
self.env = env
|
self.env = env
|
||||||
self.run_dir = run_dir
|
self.run_dir = run_dir
|
||||||
self.run_name = run_name
|
self.run_name = run_name
|
||||||
|
|
@ -464,6 +479,8 @@ class PPOTrainer:
|
||||||
|
|
||||||
action_low = jnp.asarray(self.env.single_action_space.low, dtype=jnp.float32)
|
action_low = jnp.asarray(self.env.single_action_space.low, dtype=jnp.float32)
|
||||||
action_high = jnp.asarray(self.env.single_action_space.high, dtype=jnp.float32)
|
action_high = jnp.asarray(self.env.single_action_space.high, dtype=jnp.float32)
|
||||||
|
self._action_low = action_low
|
||||||
|
self._action_high = action_high
|
||||||
|
|
||||||
self._rollout_jit = logged_jit(
|
self._rollout_jit = logged_jit(
|
||||||
partial(
|
partial(
|
||||||
|
|
@ -526,6 +543,8 @@ class PPOTrainer:
|
||||||
self.episode_stats = self._init_episode_stats()
|
self.episode_stats = self._init_episode_stats()
|
||||||
|
|
||||||
self._init_random()
|
self._init_random()
|
||||||
|
# Lazily-built JIT-compiled MJX eval rollout, created on first evaluation.
|
||||||
|
self._eval_fn = None
|
||||||
|
|
||||||
def _init_random(self):
|
def _init_random(self):
|
||||||
self.logger.info(f"[RANDOM]: Setting random seed to {self.experiment.seed}")
|
self.logger.info(f"[RANDOM]: Setting random seed to {self.experiment.seed}")
|
||||||
|
|
@ -849,6 +868,62 @@ class PPOTrainer:
|
||||||
params=self.agent_state.params, step=iteration, metadata=asdict(self.cfg)
|
params=self.agent_state.params, step=iteration, metadata=asdict(self.cfg)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _evaluate_checkpoint(self, iteration: int, *, trained_timesteps: int) -> None:
|
||||||
|
"""Evaluate the current checkpoint and persist metrics to CSV.
|
||||||
|
|
||||||
|
Delegates all evaluation logic to `evaluation.evaluate_mjx`.
|
||||||
|
Best-effort: a failure here must never abort training.
|
||||||
|
"""
|
||||||
|
if not self.evaluation_cfg.evaluate_checkpoints:
|
||||||
|
return
|
||||||
|
|
||||||
|
max_steps = int(self.evaluation_cfg.eval_max_steps)
|
||||||
|
seed = int(self.evaluation_cfg.eval_seed)
|
||||||
|
|
||||||
|
if max_steps <= 0:
|
||||||
|
self.logger.warning("[EVAL]: eval_max_steps must be > 0; skipping evaluation")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.logging_cfg.save_checkpoints or self.logging_cfg.checkpoint_frequency <= 0:
|
||||||
|
self.logger.warning(
|
||||||
|
"[EVAL]: evaluate_checkpoints is enabled but checkpoint saving is disabled; "
|
||||||
|
"skipping evaluation"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._eval_fn is None:
|
||||||
|
if getattr(self.env, "backend", None) != Backend.MJX:
|
||||||
|
self.logger.warning(
|
||||||
|
f"[EVAL]: Training env backend is {self.env.backend}; "
|
||||||
|
"MJX evaluation may be unavailable/slow."
|
||||||
|
)
|
||||||
|
self._eval_fn = build_eval_rollout_fn(
|
||||||
|
env=self.env,
|
||||||
|
obs_processor=self.obs_processor,
|
||||||
|
sensor_apply=self.sensor.apply,
|
||||||
|
actor_apply=self.actor.apply,
|
||||||
|
action_low=self._action_low,
|
||||||
|
action_high=self._action_high,
|
||||||
|
reward_fn=reward_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = evaluate_checkpoint_mjx(
|
||||||
|
self._eval_fn,
|
||||||
|
self.agent_state.params,
|
||||||
|
seed=seed,
|
||||||
|
max_steps=max_steps,
|
||||||
|
)
|
||||||
|
csv_path = append_checkpoint_eval_row(
|
||||||
|
self.run_dir,
|
||||||
|
iteration=iteration,
|
||||||
|
trained_timesteps=int(trained_timesteps),
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
self.logger.sync_file(csv_path)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"[EVAL]: Checkpoint evaluation failed: {e}")
|
||||||
|
|
||||||
def train(self):
|
def train(self):
|
||||||
"""
|
"""
|
||||||
Train the PPO agent for a specified number of iterations.
|
Train the PPO agent for a specified number of iterations.
|
||||||
|
|
@ -905,6 +980,7 @@ class PPOTrainer:
|
||||||
if self.logging_cfg.save_checkpoints and self.logging_cfg.checkpoint_frequency > 0:
|
if self.logging_cfg.save_checkpoints and self.logging_cfg.checkpoint_frequency > 0:
|
||||||
if iteration % self.logging_cfg.checkpoint_frequency == 0:
|
if iteration % self.logging_cfg.checkpoint_frequency == 0:
|
||||||
self._save_checkpoint(iteration)
|
self._save_checkpoint(iteration)
|
||||||
|
self._evaluate_checkpoint(iteration, trained_timesteps=global_step)
|
||||||
|
|
||||||
if getattr(self.cfg.experiment, "debug_sanity", False):
|
if getattr(self.cfg.experiment, "debug_sanity", False):
|
||||||
self.logger.info("\n[SANITY CHECK] Successfully completed 1 epoch")
|
self.logger.info("\n[SANITY CHECK] Successfully completed 1 epoch")
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,6 @@ class LoggingConfig:
|
||||||
"Configuration Error: 'upload_checkpoints' is True, but it requires "
|
"Configuration Error: 'upload_checkpoints' is True, but it requires "
|
||||||
"both 'track' and 'save_checkpoints' to also be True."
|
"both 'track' and 'save_checkpoints' to also be True."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# NOTE: Checkpoint evaluation settings live under the project's
|
||||||
|
# `evaluation` config group (see brittle_star_project.configs).
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ class SimpleLogger:
|
||||||
def save_final_model(self, params: Any, metadata: Optional[Dict[str, Any]] = None):
|
def save_final_model(self, params: Any, metadata: Optional[Dict[str, Any]] = None):
|
||||||
print("[SAVE] Final model would be saved (SimpleLogger: No-Op)")
|
print("[SAVE] Final model would be saved (SimpleLogger: No-Op)")
|
||||||
|
|
||||||
|
def sync_file(self, path: Any):
|
||||||
|
"""No-op for SimpleLogger."""
|
||||||
|
pass
|
||||||
|
|
||||||
def finish(self):
|
def finish(self):
|
||||||
print(f"[FINISH] SimpleLogger finished for run: {self.run_name}")
|
print(f"[FINISH] SimpleLogger finished for run: {self.run_name}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,21 @@ class UnifiedLogger:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.error(f"Error saving final model: {e}")
|
self.error(f"Error saving final model: {e}")
|
||||||
|
|
||||||
|
def sync_file(self, path: Path) -> None:
|
||||||
|
"""Upload a file to W&B if tracking is enabled.
|
||||||
|
|
||||||
|
Best-effort: logs a warning on failure, never raises.
|
||||||
|
"""
|
||||||
|
if self.wandb_run is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import wandb
|
||||||
|
|
||||||
|
# "Simple sync" behavior: wandb will copy this file into the run.
|
||||||
|
wandb.save(str(path), base_path=str(path.parent))
|
||||||
|
except Exception as e:
|
||||||
|
self.warning(f"Failed to sync file to W&B: {e}")
|
||||||
|
|
||||||
def finish(self):
|
def finish(self):
|
||||||
"""Finalize logging and cleanup."""
|
"""Finalize logging and cleanup."""
|
||||||
# Flush remaining metrics
|
# Flush remaining metrics
|
||||||
|
|
|
||||||
Reference in a new issue