Skip to content

Commit 1b8495b

Browse files
authored
Merge pull request dnv-opensource#3 from aleksandarbabicdnv/fix/ppo-observation-normalization
Fix PPO observation dtype bug and add VecNormalize
2 parents de615cd + fa44c12 commit 1b8495b

5 files changed

Lines changed: 82 additions & 21 deletions

File tree

scripts/train_ppo.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def main():
5757
trained=(args.save_path, True),
5858
)
5959
agent.do_training(args.steps)
60+
vecnorm_path = Path(args.save_path).parent / f"{Path(args.save_path).stem}_vecnorm.pkl"
6061
print(f"Model saved to {args.save_path}")
62+
print(f"VecNormalize stats saved to {vecnorm_path}")
6163

6264

6365
if __name__ == "__main__":

src/crane_controller/envs/controlled_crane_pendulum.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def __init__(
8484
self.discrete = {}
8585
self.spaces_min = np.array((-size, -max_speed, -np.pi / 2, -max_speed), float)
8686
self.spaces_max = np.array((size, max_speed, np.pi / 2, max_speed), float)
87-
self.observation_space = spaces.Box(self.spaces_min, self.spaces_max, shape=(4,), dtype=np.int64)
87+
self.observation_space = spaces.Box(self.spaces_min, self.spaces_max, shape=(4,), dtype=np.float64)
8888

8989
self.nresets: int = 0
9090
# self.reset(seed)

src/crane_controller/ppo_agent.py

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import annotations
22

3+
from pathlib import Path
34
from typing import Any
45

56
import gymnasium as gym
67
import matplotlib.pyplot as plt
78
from stable_baselines3 import PPO
89
from stable_baselines3.common.env_util import make_vec_env
910
from stable_baselines3.common.evaluation import evaluate_policy
11+
from stable_baselines3.common.vec_env import VecNormalize
1012

1113
plt.rcParams["figure.figsize"] = (10, 5)
1214

@@ -17,6 +19,7 @@ class ProximalPolicyOptimizationAgent:
1719
Initializes an agent that learns a policy via PPO algorithm to solve the task at hand.
1820
1921
PPO agents can be saved as zip file and re-loaded to avoid re-training.
22+
VecNormalize statistics are saved alongside the model as `<name>_vecnorm.pkl`.
2023
2124
Args:
2225
env (gym.Env): the environment the agent is acting on.
@@ -34,43 +37,62 @@ def __init__(
3437
trained: tuple[str, bool] | None = None,
3538
):
3639
self.trained = trained
37-
if env_kwargs is None:
38-
self.env = env() # type: ignore[operator] ## the object is callable! (__init__())
39-
else:
40-
self.env = env(**env_kwargs) # type: ignore[operator] ## the object is callable! (__init__())
41-
_n_envs = n_envs = 1 if n_envs <= 0 else n_envs
42-
self.vec_env = make_vec_env(env_id=env, n_envs=_n_envs, env_kwargs=env_kwargs) # type: ignore ## should be correct
43-
if n_envs <= 0:
44-
assert self.trained is not None, "When no training is specified a saved model should be provided"
45-
self.model = PPO.load(self.trained[0])
46-
elif n_envs == 1:
47-
self.model = PPO("MlpPolicy", self.env, verbose=1)
48-
if trained is not None:
49-
self.trained = (trained[0], trained[1])
40+
inference_only = n_envs <= 0
41+
_n_envs = 1 if inference_only else n_envs
42+
43+
raw_vec_env = make_vec_env(env_id=env, n_envs=_n_envs, env_kwargs=env_kwargs) # type: ignore
44+
45+
if inference_only:
46+
assert trained is not None, "When no training is specified a saved model should be provided"
47+
stats_path = self._stats_path(trained[0])
48+
if stats_path.exists():
49+
self.vec_env = VecNormalize.load(str(stats_path), raw_vec_env)
50+
else:
51+
self.vec_env = VecNormalize(raw_vec_env, norm_obs=True, norm_reward=False)
52+
self.vec_env.training = False
53+
self.vec_env.norm_reward = False
54+
self.model = PPO.load(trained[0], env=self.vec_env)
5055
else:
51-
self.model = PPO("MlpPolicy", self.vec_env)
56+
self.vec_env = VecNormalize(raw_vec_env, norm_obs=True, norm_reward=True)
57+
if _n_envs == 1:
58+
self.model = PPO("MlpPolicy", self.vec_env, verbose=1)
59+
else:
60+
self.model = PPO("MlpPolicy", self.vec_env)
5261
self.trained = (
53-
trained[0] if trained is not None else f"ppo_{env.__name__}", # type: ignore ## has name
62+
trained[0] if trained is not None else f"ppo_{env.__name__}", # type: ignore[attr-defined]
5463
False if trained is None else trained[1],
5564
)
5665

66+
# Single unwrapped env for do_one_episode/evaluate without reconstructing a new crane.
67+
self.env = self.vec_env.venv.envs[0] # type: ignore[attr-defined]
68+
69+
@staticmethod
70+
def _stats_path(model_path: str) -> Path:
71+
p = Path(model_path)
72+
return p.parent / f"{p.stem}_vecnorm.pkl"
73+
5774
def do_training(self, total_timesteps: int = 25000, progress_bar: bool = True):
5875
self.model.learn(total_timesteps, progress_bar=progress_bar)
59-
if self.trained is not None and self.trained[1] and self.env.render_mode not in ("play-back"):
76+
if self.trained is not None and self.trained[1] and self.env.render_mode not in ("play-back",):
6077
self.model.save(self.trained[0])
78+
self.vec_env.save(str(self._stats_path(self.trained[0])))
6179

6280
def evaluate(self, n_episodes: int = 10):
63-
mean_reward, std_reward = evaluate_policy(self.model, self.env, n_eval_episodes=n_episodes)
81+
self.vec_env.training = False
82+
self.vec_env.norm_reward = False
83+
mean_reward, std_reward = evaluate_policy(self.model, self.vec_env, n_eval_episodes=n_episodes)
84+
self.vec_env.training = True
85+
self.vec_env.norm_reward = True
6486
print(f"Mean:{mean_reward}, stdev:{std_reward}")
6587

6688
def do_one_episode(self, seed: int = 1):
67-
"""Do one episode on the non-vectorized, trained environment."""
89+
"""Do one episode using the trained normalizer for observations."""
6890
obs, info = self.env.reset(seed=seed)
6991
terminated = truncated = False
7092
while not terminated and not truncated:
71-
action, _states = self.model.predict(obs)
93+
norm_obs = self.vec_env.normalize_obs(obs)
94+
action, _states = self.model.predict(norm_obs, deterministic=True)
7295
obs, rewards, terminated, truncated, info = self.env.step(action)
73-
# print("Action", obs, rewards, terminated, truncated, info)
7496
self.env.render()
7597

7698

tests/test_environment.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ def test_environment(crane: Callable, show: bool, v0: float = 1.0, reward_limit=
7676
assert q_values[obs2.tobytes()][2] == -98.1
7777

7878

79+
def test_observation_space_dtype(crane: Callable):
80+
env = AntiPendulumEnv(crane)
81+
assert env.observation_space.dtype == np.float64
82+
83+
84+
def test_observations_are_float(crane: Callable):
85+
env = AntiPendulumEnv(crane)
86+
env.reset()
87+
obs, _, _, _, _ = env.step(1) # one physics step produces fractional values
88+
assert obs.dtype == np.float64
89+
assert not np.all(obs == obs.astype(int)) # sub-integer precision is preserved
90+
91+
7992
def test_init(crane: Crane, show: bool = False):
8093
"""Test the initialization of the environment."""
8194
env = AntiPendulumEnv(crane, seed=1, start_speed=1.0, render_mode="play-back" if show else "data")

tests/test_ppo.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2+
from pathlib import Path
23

4+
import numpy as np
35
from py_crane.crane import Crane
46

57
from crane_controller.envs.controlled_crane_pendulum import AntiPendulumEnv
@@ -20,3 +22,25 @@ def test_monitor(crane: Crane, show: bool):
2022
},
2123
)
2224
agent.do_training(1000)
25+
26+
27+
def test_ppo_saves_vecnorm(crane, tmp_path):
28+
save_path = str(tmp_path / "model.zip")
29+
agent = ProximalPolicyOptimizationAgent(
30+
AntiPendulumEnv, # type: ignore[arg-type]
31+
n_envs=1,
32+
env_kwargs={"crane": crane, "start_speed": 1.0},
33+
trained=(save_path, True),
34+
)
35+
agent.do_training(500, progress_bar=False)
36+
assert (tmp_path / "model_vecnorm.pkl").exists()
37+
38+
39+
def test_ppo_vecnorm_updates(crane):
40+
agent = ProximalPolicyOptimizationAgent(
41+
AntiPendulumEnv, # type: ignore[arg-type]
42+
n_envs=1,
43+
env_kwargs={"crane": crane, "start_speed": 1.0},
44+
)
45+
agent.do_training(500, progress_bar=False)
46+
assert not np.allclose(agent.vec_env.obs_rms.mean, 0.0)

0 commit comments

Comments
 (0)