Skip to content

Commit d1f1eb0

Browse files
committed
[Feature] MAPPOLoss + IPPOLoss + MultiAgentGAE + ValueNorm
1 parent cc31dc3 commit d1f1eb0

22 files changed

Lines changed: 1283 additions & 30 deletions

docs/source/reference/objectives.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ Documentation Sections
5050
objectives_policy
5151
objectives_actorcritic
5252
objectives_offline
53+
objectives_multiagent
5354
objectives_other

docs/source/reference/objectives_common.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Value Estimators
2828
TD1Estimator
2929
TDLambdaEstimator
3030
GAE
31+
MultiAgentGAE
3132

3233
.. currentmodule:: torchrl.objectives
3334

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
.. currentmodule:: torchrl.objectives.multiagent
2+
3+
Multi-Agent Objectives
4+
======================
5+
6+
Loss modules for multi-agent reinforcement learning algorithms. These losses
7+
follow the torchrl multi-agent tensordict convention (per-agent tensors
8+
nested under group keys such as ``("agents", "observation")``; see
9+
:class:`~torchrl.envs.libs.vmas.VmasEnv` and
10+
:class:`~torchrl.envs.libs.pettingzoo.PettingZooEnv`).
11+
12+
MAPPO and IPPO
13+
--------------
14+
15+
:class:`MAPPOLoss` implements Multi-Agent PPO (Yu et al. 2022) — a
16+
decentralised actor paired with a *centralised critic* that conditions on the
17+
joint observation / state. :class:`IPPOLoss` is the independent-learner
18+
counterpart from de Witt et al. 2020: each agent has its own local critic and
19+
there is no centralised information at training time.
20+
21+
Both are thin specialisations of :class:`~torchrl.objectives.ClipPPOLoss`
22+
that:
23+
24+
- default the value estimator to
25+
:class:`~torchrl.objectives.value.MultiAgentGAE`, which broadcasts
26+
team-shared rewards / done flags across the agent dimension before
27+
computing returns;
28+
- default ``normalize_advantage_exclude_dims`` to ``(-2,)`` so the agent dim
29+
is excluded from advantage standardisation;
30+
- optionally accept a :class:`~torchrl.modules.ValueNorm` (PopArt-style
31+
running normaliser) to stabilise the critic loss when reward scales drift
32+
during training — the trick the MAPPO paper credits for its strong SMAC
33+
results.
34+
35+
See ``sota-implementations/multiagent/mappo_ippo.py`` for a hydra-configured
36+
recipe and ``examples/multiagent/mappo_vmas.py`` for a minimal one.
37+
38+
.. autosummary::
39+
:toctree: generated/
40+
:template: rl_template_noinherit.rst
41+
42+
MAPPOLoss
43+
IPPOLoss
44+
45+
QMixer
46+
------
47+
48+
:class:`QMixerLoss` mixes local per-agent Q values into a global team Q
49+
value via a learnable mixing network, and trains them jointly with a DQN
50+
update on the global value (Rashid et al. 2018).
51+
52+
.. autosummary::
53+
:toctree: generated/
54+
:template: rl_template_noinherit.rst
55+
56+
QMixerLoss

examples/multiagent/mappo_vmas.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
"""Minimal MAPPO / IPPO recipe on VMAS using the new
6+
:class:`~torchrl.objectives.multiagent.MAPPOLoss` /
7+
:class:`~torchrl.objectives.multiagent.IPPOLoss` classes.
8+
9+
For the full, hydra-configured, wandb-logged version see
10+
``sota-implementations/multiagent/mappo_ippo.py``. This file is intentionally
11+
short: it's there to show that the new loss classes collapse the boilerplate
12+
that previously required ``ClipPPOLoss`` + manual ``set_keys(done=...,
13+
terminated=...)`` + manual ``make_value_estimator(GAE, ...)`` into a single
14+
construction call.
15+
16+
Usage::
17+
18+
python examples/multiagent/mappo_vmas.py --algo mappo --frames 200_000
19+
python examples/multiagent/mappo_vmas.py --algo ippo --frames 200_000
20+
21+
The two should reach similar reward on the easy navigation scenario; MAPPO
22+
typically pulls ahead on harder coordination tasks (Yu et al. 2022).
23+
"""
24+
from __future__ import annotations
25+
26+
import argparse
27+
import time
28+
29+
import torch
30+
from tensordict.nn import TensorDictModule
31+
from tensordict.nn.distributions import NormalParamExtractor
32+
from torch import nn
33+
34+
from torchrl.collectors import SyncDataCollector
35+
from torchrl.data import LazyTensorStorage, TensorDictReplayBuffer
36+
from torchrl.data.replay_buffers.samplers import SamplerWithoutReplacement
37+
from torchrl.envs import RewardSum, TransformedEnv
38+
from torchrl.modules import MultiAgentMLP, ProbabilisticActor, TanhNormal, ValueNorm
39+
from torchrl.objectives import IPPOLoss, MAPPOLoss
40+
41+
42+
def make_actor(env, *, share_params: bool = True) -> ProbabilisticActor:
43+
obs_dim = env.observation_spec["agents", "observation"].shape[-1]
44+
action_dim = env.action_spec.shape[-1]
45+
backbone = nn.Sequential(
46+
MultiAgentMLP(
47+
n_agent_inputs=obs_dim,
48+
n_agent_outputs=2 * action_dim,
49+
n_agents=env.n_agents,
50+
centralized=False,
51+
share_params=share_params,
52+
depth=2,
53+
num_cells=256,
54+
activation_class=nn.Tanh,
55+
),
56+
NormalParamExtractor(),
57+
)
58+
module = TensorDictModule(
59+
backbone,
60+
in_keys=[("agents", "observation")],
61+
out_keys=[("agents", "loc"), ("agents", "scale")],
62+
)
63+
return ProbabilisticActor(
64+
module=module,
65+
in_keys=[("agents", "loc"), ("agents", "scale")],
66+
out_keys=[env.action_key],
67+
distribution_class=TanhNormal,
68+
distribution_kwargs={
69+
"low": env.full_action_spec_unbatched[("agents", "action")].space.low,
70+
"high": env.full_action_spec_unbatched[("agents", "action")].space.high,
71+
},
72+
return_log_prob=True,
73+
)
74+
75+
76+
def make_critic(
77+
env, *, centralized: bool, share_params: bool = True
78+
) -> TensorDictModule:
79+
obs_dim = env.observation_spec["agents", "observation"].shape[-1]
80+
return TensorDictModule(
81+
MultiAgentMLP(
82+
n_agent_inputs=obs_dim,
83+
n_agent_outputs=1,
84+
n_agents=env.n_agents,
85+
centralized=centralized,
86+
share_params=share_params,
87+
depth=2,
88+
num_cells=256,
89+
activation_class=nn.Tanh,
90+
),
91+
in_keys=[("agents", "observation")],
92+
out_keys=[("agents", "state_value")],
93+
)
94+
95+
96+
def main(args: argparse.Namespace) -> None:
97+
try:
98+
from torchrl.envs.libs.vmas import VmasEnv
99+
except ImportError as exc:
100+
raise SystemExit(
101+
"This example requires VMAS. Install it with `pip install vmas`."
102+
) from exc
103+
104+
device = "cuda" if torch.cuda.is_available() else "cpu"
105+
torch.manual_seed(args.seed)
106+
107+
n_envs = max(1, args.frames_per_batch // args.max_steps)
108+
env = TransformedEnv(
109+
VmasEnv(
110+
scenario=args.scenario,
111+
num_envs=n_envs,
112+
continuous_actions=True,
113+
max_steps=args.max_steps,
114+
device=device,
115+
seed=args.seed,
116+
),
117+
RewardSum(
118+
in_keys=[("next", "agents", "reward")],
119+
out_keys=[("agents", "episode_reward")],
120+
)
121+
if False
122+
else RewardSum(in_keys=["reward"], out_keys=["episode_reward"]),
123+
)
124+
125+
actor = make_actor(env)
126+
centralised = args.algo == "mappo"
127+
critic = make_critic(env, centralized=centralised)
128+
129+
LossCls = MAPPOLoss if args.algo == "mappo" else IPPOLoss
130+
value_norm = ValueNorm(shape=1, device=device) if args.algo == "mappo" else None
131+
loss_module = LossCls(
132+
actor_network=actor,
133+
critic_network=critic,
134+
value_norm=value_norm,
135+
clip_epsilon=0.2,
136+
entropy_coeff=0.01,
137+
)
138+
loss_module.set_keys(
139+
value=("agents", "state_value"),
140+
action=env.action_key,
141+
reward=env.reward_key,
142+
)
143+
144+
collector = SyncDataCollector(
145+
env,
146+
actor,
147+
device=device,
148+
storing_device=device,
149+
frames_per_batch=args.frames_per_batch,
150+
total_frames=args.frames,
151+
)
152+
153+
replay_buffer = TensorDictReplayBuffer(
154+
storage=LazyTensorStorage(args.frames_per_batch, device=device),
155+
sampler=SamplerWithoutReplacement(),
156+
batch_size=args.minibatch_size,
157+
)
158+
159+
optim = torch.optim.Adam(loss_module.parameters(), lr=args.lr)
160+
161+
total_frames = 0
162+
start = time.time()
163+
for it, td in enumerate(collector):
164+
with torch.no_grad():
165+
loss_module.value_estimator(
166+
td,
167+
params=loss_module.critic_network_params,
168+
target_params=loss_module.target_critic_network_params,
169+
)
170+
replay_buffer.extend(td.reshape(-1))
171+
total_frames += td.numel()
172+
173+
for _ in range(args.epochs):
174+
for _ in range(args.frames_per_batch // args.minibatch_size):
175+
subdata = replay_buffer.sample()
176+
losses = loss_module(subdata)
177+
loss = (
178+
losses["loss_objective"]
179+
+ losses["loss_critic"]
180+
+ losses["loss_entropy"]
181+
)
182+
optim.zero_grad()
183+
loss.backward()
184+
torch.nn.utils.clip_grad_norm_(loss_module.parameters(), 1.0)
185+
optim.step()
186+
187+
collector.update_policy_weights_()
188+
ep_reward = td.get(("next", "episode_reward")).mean().item()
189+
print(
190+
f"[{args.algo}] iter={it:03d} frames={total_frames:>7d} "
191+
f"reward={ep_reward:+.3f} elapsed={time.time() - start:5.1f}s"
192+
)
193+
194+
collector.shutdown()
195+
if not env.is_closed:
196+
env.close()
197+
198+
199+
def parse_args() -> argparse.Namespace:
200+
p = argparse.ArgumentParser()
201+
p.add_argument("--algo", choices=("mappo", "ippo"), default="mappo")
202+
p.add_argument("--scenario", default="navigation")
203+
p.add_argument("--frames", type=int, default=200_000)
204+
p.add_argument("--frames_per_batch", type=int, default=6_000)
205+
p.add_argument("--minibatch_size", type=int, default=400)
206+
p.add_argument("--max_steps", type=int, default=100)
207+
p.add_argument("--epochs", type=int, default=4)
208+
p.add_argument("--lr", type=float, default=3e-4)
209+
p.add_argument("--seed", type=int, default=0)
210+
return p.parse_args()
211+
212+
213+
if __name__ == "__main__":
214+
main(parse_args())

test/objectives/test_cql.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def test_cql(
182182
delay_qvalue=delay_qvalue,
183183
)
184184

185-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
185+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
186186
with pytest.raises(NotImplementedError):
187187
loss_fn.make_value_estimator(td_est)
188188
return
@@ -361,7 +361,7 @@ def test_cql_deactivate_vmap(
361361
deactivate_vmap=False,
362362
)
363363

364-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
364+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
365365
with pytest.raises(NotImplementedError):
366366
loss_fn_vmap.make_value_estimator(td_est)
367367
return
@@ -389,7 +389,7 @@ def test_cql_deactivate_vmap(
389389
deactivate_vmap=True,
390390
)
391391

392-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
392+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
393393
with pytest.raises(NotImplementedError):
394394
loss_fn_no_vmap.make_value_estimator(td_est)
395395
return
@@ -845,7 +845,7 @@ def test_dcql(self, delay_value, device, action_spec_type, td_est):
845845
action_spec_type=action_spec_type, device=device
846846
)
847847
loss_fn = DiscreteCQLLoss(actor, loss_function="l2", delay_value=delay_value)
848-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
848+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
849849
with pytest.raises(NotImplementedError):
850850
loss_fn.make_value_estimator(td_est)
851851
return

test/objectives/test_ddpg.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def test_ddpg(self, delay_actor, delay_value, device, td_est):
248248
delay_actor=delay_actor,
249249
delay_value=delay_value,
250250
)
251-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
251+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
252252
with pytest.raises(NotImplementedError):
253253
loss_fn.make_value_estimator(td_est)
254254
return
@@ -1024,7 +1024,7 @@ def test_td3(
10241024
delay_actor=delay_actor,
10251025
delay_qvalue=delay_qvalue,
10261026
)
1027-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
1027+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
10281028
with pytest.raises(NotImplementedError):
10291029
loss_fn.make_value_estimator(td_est)
10301030
return
@@ -1136,7 +1136,7 @@ def test_td3_deactivate_vmap(
11361136
delay_actor=delay_actor,
11371137
delay_qvalue=delay_qvalue,
11381138
)
1139-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
1139+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
11401140
with pytest.raises(NotImplementedError):
11411141
loss_fn_vmap.make_value_estimator(td_est)
11421142
return
@@ -1166,7 +1166,7 @@ def test_td3_deactivate_vmap(
11661166
delay_actor=delay_actor,
11671167
delay_qvalue=delay_qvalue,
11681168
)
1169-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
1169+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
11701170
with pytest.raises(NotImplementedError):
11711171
loss_fn_no_vmap.make_value_estimator(td_est)
11721172
return
@@ -1937,7 +1937,7 @@ def test_td3bc(
19371937
delay_actor=delay_actor,
19381938
delay_qvalue=delay_qvalue,
19391939
)
1940-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
1940+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
19411941
with pytest.raises(NotImplementedError):
19421942
loss_fn.make_value_estimator(td_est)
19431943
return

test/objectives/test_dqn.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def test_dqn(self, delay_value, double_dqn, device, action_spec_type, td_est):
258258
delay_value=delay_value,
259259
double_dqn=double_dqn,
260260
)
261-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
261+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
262262
with pytest.raises(NotImplementedError):
263263
loss_fn.make_value_estimator(td_est)
264264
return
@@ -922,7 +922,7 @@ def test_qmixer(self, delay_value, device, action_spec_type, td_est):
922922
action_spec_type=action_spec_type, device=device
923923
)
924924
loss_fn = QMixerLoss(actor, mixer, loss_function="l2", delay_value=delay_value)
925-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
925+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
926926
with pytest.raises(NotImplementedError):
927927
loss_fn.make_value_estimator(td_est)
928928
return

test/objectives/test_dreamer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ def test_dreamer_actor(self, device, imagination_horizon, discount_loss, td_est)
406406
imagination_horizon=imagination_horizon,
407407
discount_loss=discount_loss,
408408
)
409-
if td_est in (ValueEstimators.GAE, ValueEstimators.VTrace):
409+
if td_est in (ValueEstimators.GAE, ValueEstimators.MAGAE, ValueEstimators.VTrace):
410410
with pytest.raises(NotImplementedError):
411411
loss_module.make_value_estimator(td_est)
412412
return

0 commit comments

Comments
 (0)