Skip to content

Commit bfa4eb7

Browse files
committed
Tighten test style
1 parent 8d4dd8c commit bfa4eb7

7 files changed

Lines changed: 201 additions & 73 deletions

File tree

tests/algorithms/test_on_policy.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@
1515

1616

1717
class OnPolicyAlgorithm[ActType, ObsType](AbstractOnPolicyAlgorithm[ActType, ObsType]):
18-
"""Minimal concrete on-policy algorithm just for testing."""
19-
2018
env: AbstractEnvLike[ActType, ObsType]
2119
policy: AbstractActorCriticPolicy[Float, ActType, ObsType]
2220
state_index: eqx.nn.StateIndex[None] = eqx.nn.StateIndex(None)

tests/algorithms/test_ppo.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import equinox as eqx
44
import jax
5+
import pytest
56
from jax import numpy as jnp
67
from jax import random as jr
78

89
from oryx.algorithm import PPO
910
from oryx.policy.actor_critic import CustomActorCriticPolicy
1011
from oryx.space import Box
12+
from tests.buffers import empty_buffer
1113
from tests.envs import EchoEnv
14+
from tests.policies import ConstantPolicy
1215

1316

1417
def _make_ppo(num_steps: int = 8) -> tuple[PPO, eqx.nn.State]:
@@ -86,3 +89,95 @@ def test_learn_integration(self):
8689
pol_state = state.substate(policy)
8790
pol_state, act = policy.predict(pol_state, obs, key=jr.key(5))
8891
assert env.action_space.contains(act)
92+
93+
def test_ppo_loss_clip_vs_unclipped_and_entropy(self):
94+
buf = empty_buffer(n=6)
95+
pol = ConstantPolicy(new_value=5.0, logp=0.0, entropy_val=2.0)
96+
97+
loss_c, stats_c = PPO.ppo_loss(
98+
pol,
99+
buf,
100+
normalize_advantages=False,
101+
clip_coefficient=0.1,
102+
clip_value_loss=True,
103+
value_loss_coefficient=0.5,
104+
state_magnitude_coefficient=0.0,
105+
entropy_loss_coefficient=0.01,
106+
)
107+
policy_loss = -1.0
108+
value_loss_clipped = 0.5 * (0.1**2)
109+
entropy = 2.0
110+
expected_total = policy_loss + 0.5 * value_loss_clipped - 0.01 * entropy
111+
112+
assert float(stats_c.policy_loss) == pytest.approx(policy_loss)
113+
assert float(stats_c.value_loss) == pytest.approx(value_loss_clipped)
114+
assert float(stats_c.entropy_loss) == pytest.approx(entropy)
115+
assert float(stats_c.state_magnitude_loss) == pytest.approx(0.0)
116+
assert float(stats_c.total_loss) == pytest.approx(expected_total, rel=1e-6)
117+
assert float(stats_c.approx_kl) == pytest.approx(0.0)
118+
119+
loss_u, stats_u = PPO.ppo_loss(
120+
pol,
121+
buf,
122+
normalize_advantages=False,
123+
clip_coefficient=0.1,
124+
clip_value_loss=False,
125+
value_loss_coefficient=0.5,
126+
state_magnitude_coefficient=0.0,
127+
entropy_loss_coefficient=0.01,
128+
)
129+
assert float(stats_u.value_loss) == pytest.approx(12.5)
130+
expected_total_unclipped = policy_loss + 0.5 * 12.5 - 0.01 * entropy
131+
assert float(stats_u.total_loss) == pytest.approx(
132+
expected_total_unclipped, rel=1e-6
133+
)
134+
assert expected_total_unclipped != pytest.approx(expected_total)
135+
136+
137+
def _setup_algo(*, anneal: bool):
138+
key = jr.key(0)
139+
env = EchoEnv(space=Box(-jnp.ones(2), jnp.ones(2)))
140+
policy, _ = eqx.nn.make_with_state(CustomActorCriticPolicy)(env=env, key=key)
141+
algo, state = eqx.nn.make_with_state(PPO)(
142+
env=env,
143+
policy=policy,
144+
num_steps=8,
145+
num_epochs=2,
146+
num_mini_batches=2,
147+
learning_rate=1e-3,
148+
anneal_learning_rate=anneal,
149+
)
150+
return algo, state
151+
152+
153+
def _collect_once(algo, state, *, key):
154+
state, carry = algo.initialize_iteration_carry(state, key=key)
155+
state, _, buf, _ = algo.collect_rollout(
156+
algo.policy, state, carry.step_carry, key=jr.split(key)[0]
157+
)
158+
return state, buf
159+
160+
161+
def test_learning_rate_anneals_down():
162+
algo, state = _setup_algo(anneal=True)
163+
state, buf = _collect_once(algo, state, key=jr.key(1))
164+
165+
lr0 = float(algo.learning_rate(state))
166+
state, _, _ = algo.train(state, algo.policy, buf, key=jr.key(2))
167+
lr1 = float(algo.learning_rate(state))
168+
state, _, _ = algo.train(state, algo.policy, buf, key=jr.key(3))
169+
lr2 = float(algo.learning_rate(state))
170+
171+
assert lr0 > lr1 > lr2
172+
173+
174+
def test_learning_rate_constant_when_no_anneal():
175+
algo, state = _setup_algo(anneal=False)
176+
state, buf = _collect_once(algo, state, key=jr.key(4))
177+
178+
lr0 = float(algo.learning_rate(state))
179+
state, _, _ = algo.train(state, algo.policy, buf, key=jr.key(5))
180+
lr1 = float(algo.learning_rate(state))
181+
182+
assert lr0 == pytest.approx(1e-3)
183+
assert lr1 == pytest.approx(lr0)

tests/buffers/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from jax import numpy as jnp
2+
3+
from oryx.buffer import RolloutBuffer
4+
5+
6+
def empty_buffer(n: int = 8):
7+
observations = jnp.zeros((n,))
8+
actions = jnp.zeros((n,))
9+
rewards = jnp.zeros((n,))
10+
terminations = jnp.full((n,), False)
11+
truncations = jnp.full((n,), False)
12+
log_probs = jnp.zeros((n,))
13+
values = jnp.zeros((n,))
14+
states = jnp.zeros((n,))
15+
returns = jnp.zeros((n,))
16+
advantages = jnp.ones((n,))
17+
18+
return RolloutBuffer(
19+
observations=observations,
20+
actions=actions,
21+
rewards=rewards,
22+
terminations=terminations,
23+
truncations=truncations,
24+
log_probs=log_probs,
25+
values=values,
26+
states=states,
27+
returns=returns,
28+
advantages=advantages,
29+
)

tests/buffers/test_rollout.py

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from jax import random as jr
77

88
from oryx.buffer import RolloutBuffer
9-
from oryx.distribution import Normal
10-
from oryx.policy import AbstractActorCriticPolicy
119
from oryx.utils import filter_scan
1210
from tests.envs import EchoEnv
11+
from tests.policies import NormalPolicy
1312

1413

1514
class TestRolloutBuffer:
@@ -108,43 +107,13 @@ def test_batches_handles_non_divisible_batch_size(self):
108107

109108

110109
class TestRolloutBufferWithEnv:
111-
112-
class _SimplePolicy(AbstractActorCriticPolicy):
113-
env: EchoEnv
114-
state_index: eqx.nn.StateIndex[None] = eqx.nn.StateIndex(None)
115-
116-
def __init__(self, env: EchoEnv):
117-
self.env = env
118-
119-
@property
120-
def action_space(self):
121-
return self.env.action_space
122-
123-
@property
124-
def observation_space(self):
125-
return self.env.observation_space
126-
127-
def extract_features(self, state, observation):
128-
return state, jnp.asarray(observation)
129-
130-
def action_dist_from_features(self, state, features):
131-
return state, Normal(features, jnp.asarray(1.0))
132-
133-
def value_from_features(self, state, features):
134-
return state, jnp.asarray(0.0)
135-
136-
def reset(self, state):
137-
return state
138-
139110
def test_env_policy_collection_and_batching(
140111
self, num_steps: int = 8, batch_size: int = 2
141112
):
142113
reset_key, scan_key, batch_key = jr.split(jr.key(0), 3)
143114

144115
env = EchoEnv()
145-
policy, state = eqx.nn.make_with_state(TestRolloutBufferWithEnv._SimplePolicy)(
146-
env=env
147-
)
116+
policy, state = eqx.nn.make_with_state(NormalPolicy)(env=env)
148117

149118
state, obs, _ = env.reset(state, key=reset_key)
150119

tests/envs/test_base_env.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,16 @@
66
from jax import random as jr
77

88
from oryx.env import AbstractEnv, AbstractEnvLike
9+
from oryx.wrapper import Identity
910
from tests.envs import DummyEnv
1011

1112

1213
class TestAbstractEnvLike:
1314
def test_cannot_instantiate_abstract(self):
14-
"""Direct instantiation of abstract bases must fail."""
1515
with pytest.raises(TypeError):
1616
AbstractEnvLike() # pyright: ignore
1717

1818
def test_missing_methods(self):
19-
"""A subclass that omits a required method should be abstract."""
20-
2119
class NoStepEnv(AbstractEnvLike):
2220
def reset(self, state, *, key):
2321
raise NotImplementedError
@@ -39,13 +37,10 @@ def close(self): ...
3937

4038
class TestAbstractEnv:
4139
def test_cannot_instantiate_abstract(self):
42-
"""Direct instantiation of abstract bases must fail."""
4340
with pytest.raises(TypeError):
4441
AbstractEnv() # pyright: ignore
4542

4643
def test_missing_methods(self):
47-
"""A subclass that omits a required method should be abstract."""
48-
4944
class NoStepEnv(AbstractEnv):
5045
def reset(self, state, *, key):
5146
raise NotImplementedError
@@ -65,7 +60,6 @@ def close(self): ...
6560
NoStepEnv() # pyright: ignore
6661

6762
def test_dummy_env_reset_and_step_shapes(self):
68-
"""`reset` and `step` should respect the declared spaces & signatures."""
6963
key = jr.key(0)
7064
env, state = eqx.nn.make_with_state(DummyEnv)(key=key)
7165

@@ -86,30 +80,7 @@ def test_dummy_env_reset_and_step_shapes(self):
8680
assert info == {}
8781

8882
def test_unwrapped_returns_base_env(self):
89-
"""Even after extra wrapping, `.unwrapped` should reach the original env."""
90-
from oryx.wrapper.base_wrapper import AbstractNoRenderOrCloseWrapper
91-
92-
class IdentityWrapper(AbstractNoRenderOrCloseWrapper):
93-
env: DummyEnv
94-
95-
def __init__(self, env):
96-
self.env = env
97-
98-
def reset(self, state, *, key):
99-
return self.env.reset(state, key=key)
100-
101-
def step(self, state, action, *, key):
102-
return self.env.step(state, action, key=key)
103-
104-
@property
105-
def action_space(self):
106-
return self.env.action_space
107-
108-
@property
109-
def observation_space(self):
110-
return self.env.observation_space
111-
11283
base_env, _ = eqx.nn.make_with_state(DummyEnv)(key=jr.key(0))
113-
wrapped_env = IdentityWrapper(base_env)
84+
wrapped_env = Identity(base_env)
11485

115-
assert wrapped_env.unwrapped is base_env, "unwrapped must expose base env"
86+
assert wrapped_env.unwrapped is base_env

tests/policies/__init__.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,70 @@
1+
import equinox as eqx
2+
from jax import numpy as jnp
13

4+
from oryx.distribution import Normal
5+
from oryx.policy.actor_critic import AbstractActorCriticPolicy
6+
from oryx.space import Box
7+
from tests.envs import EchoEnv
8+
9+
10+
class ConstantPolicy(AbstractActorCriticPolicy):
11+
new_value: float
12+
logp: float
13+
entropy_val: float
14+
15+
state_index: eqx.nn.StateIndex[None] = eqx.nn.StateIndex(None)
16+
action_space = Box(-jnp.inf, jnp.inf, shape=())
17+
observation_space = Box(-jnp.inf, jnp.inf, shape=())
18+
19+
def __init__(self, new_value: float, logp: float = 0.0, entropy_val: float = 0.0):
20+
self.new_value = float(new_value)
21+
self.logp = float(logp)
22+
self.entropy_val = float(entropy_val)
23+
24+
def extract_features(self, state, observation):
25+
return state, jnp.asarray(0.0)
26+
27+
def action_dist_from_features(self, state, features):
28+
raise NotImplementedError
29+
30+
def value_from_features(self, state, features):
31+
return state, jnp.asarray(self.new_value)
32+
33+
def reset(self, state):
34+
return state
35+
36+
def evaluate_action(self, state, observation, action):
37+
return (
38+
state,
39+
jnp.asarray(self.new_value),
40+
jnp.asarray(self.logp),
41+
jnp.asarray(self.entropy_val),
42+
)
43+
44+
45+
class NormalPolicy(AbstractActorCriticPolicy):
46+
env: EchoEnv
47+
state_index: eqx.nn.StateIndex[None] = eqx.nn.StateIndex(None)
48+
49+
def __init__(self, env: EchoEnv):
50+
self.env = env
51+
52+
@property
53+
def action_space(self):
54+
return self.env.action_space
55+
56+
@property
57+
def observation_space(self):
58+
return self.env.observation_space
59+
60+
def extract_features(self, state, observation):
61+
return state, jnp.asarray(observation)
62+
63+
def action_dist_from_features(self, state, features):
64+
return state, Normal(features, jnp.asarray(1.0))
65+
66+
def value_from_features(self, state, features):
67+
return state, jnp.asarray(0.0)
68+
69+
def reset(self, state):
70+
return state

tests/test_utils.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,6 @@ def _compute(x):
8282

8383
@pytest.mark.parametrize("thread_flag", [False, True])
8484
def test_debug_wrapper_threading(thread_flag):
85-
"""
86-
Ensure the callback fires and – when requested – in a different thread.
87-
"""
8885
wrapped = debug_wrapper(_compute, thread=thread_flag)
8986

9087
@jax.jit
@@ -97,12 +94,12 @@ def f(x):
9794
time.sleep(0.05)
9895

9996
assert float(result) == pytest.approx(4.14)
100-
assert hasattr(_compute, "thread_id"), "callback never executed"
97+
assert hasattr(_compute, "thread_id")
10198

10299
if thread_flag:
103-
assert _compute.thread_id != main_thread, "expected a different thread"
100+
assert _compute.thread_id != main_thread
104101
else:
105-
assert _compute.thread_id == main_thread, "should run on main thread"
102+
assert _compute.thread_id == main_thread
106103

107104

108105
def _array_collector(x):

0 commit comments

Comments
 (0)