dsh-eval-guard 0.7.4 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ISSUES.md +2 -2
- package/README.md +1 -1
- package/assets/neural-pendulum/SPEC.md +2 -0
- package/assets/neural-pendulum/TASK.md +3 -1
- package/assets/neural-pendulum/sim.cpp +25 -3
- package/assets/neural-pendulum/sim_agent +0 -0
- package/assets/neural-pendulum/sim_eval +0 -0
- package/assets/neural-pendulum/workspace.zip +0 -0
- package/neural-pendulum-eval/README.md +4 -2
- package/neural-pendulum-eval/run_eval.py +3 -1
- package/neural-pendulum-eval/sim_eval +0 -0
- package/package.json +1 -1
- package/assets/neural-pendulum/rl-feasibility/README.md +0 -60
- package/assets/neural-pendulum/rl-feasibility/RESULTS.md +0 -370
- package/assets/neural-pendulum/rl-feasibility/evaluate.py +0 -107
- package/assets/neural-pendulum/rl-feasibility/finalize_run.py +0 -168
- package/assets/neural-pendulum/rl-feasibility/models.py +0 -124
- package/assets/neural-pendulum/rl-feasibility/obs.py +0 -205
- package/assets/neural-pendulum/rl-feasibility/plot_selected.py +0 -55
- package/assets/neural-pendulum/rl-feasibility/reference_controller.py +0 -112
- package/assets/neural-pendulum/rl-feasibility/results/curves_selected.png +0 -0
- package/assets/neural-pendulum/rl-feasibility/results/reference_controller.json +0 -417
- package/assets/neural-pendulum/rl-feasibility/results/summary.json +0 -1599
- package/assets/neural-pendulum/rl-feasibility/results/table.md +0 -39
- package/assets/neural-pendulum/rl-feasibility/sim.py +0 -324
- package/assets/neural-pendulum/rl-feasibility/summarize.py +0 -175
- package/assets/neural-pendulum/rl-feasibility/task.py +0 -342
- package/assets/neural-pendulum/rl-feasibility/test_contract.py +0 -188
- package/assets/neural-pendulum/rl-feasibility/train_ppo.py +0 -403
- package/assets/neural-pendulum/rl-feasibility/train_sac.py +0 -406
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""Deterministic evaluation at the fixed initial state (and optional starts).
|
|
2
|
-
|
|
3
|
-
The evaluation uses the simulator API exactly like training (angles only), the
|
|
4
|
-
same observation builder / obs mode, and the same trainer-side reward. There
|
|
5
|
-
is no action noise.
|
|
6
|
-
|
|
7
|
-
default fixed state: theta = [0.013, -0.008, 0.021] rad, omega = 0
|
|
8
|
-
optional starts : drawn from the training reset distribution
|
|
9
|
-
|
|
10
|
-
The episode always runs the full 3000 steps so that t_can, ever_up, return and
|
|
11
|
-
the final angle error are comparable across algorithms.
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import numpy as np
|
|
16
|
-
import torch
|
|
17
|
-
|
|
18
|
-
from obs import DT, make_numpy_obs, wrap_to_pi
|
|
19
|
-
from sim import N_ACT, N_LINKS, UP_TOL, PendulumSim
|
|
20
|
-
from task import EVAL_OMEGA0, EVAL_THETA0, NumpyTask
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def evaluate(policy_fn, p, episode_steps=3000, reward_w=None, obs_mode="full"):
|
|
24
|
-
"""Run one full deterministic episode at the fixed initial state.
|
|
25
|
-
|
|
26
|
-
policy_fn(obs: np.ndarray[obs_dim]) -> np.ndarray[2]
|
|
27
|
-
"""
|
|
28
|
-
sim = PendulumSim(p, episode_steps=episode_steps)
|
|
29
|
-
task = NumpyTask(p, reward_w=reward_w, obs_mode=obs_mode)
|
|
30
|
-
theta = sim.reset(EVAL_THETA0, EVAL_OMEGA0)
|
|
31
|
-
obs = task.reset(theta)
|
|
32
|
-
acts = np.zeros((episode_steps, N_ACT), dtype=np.float64)
|
|
33
|
-
for k in range(episode_steps):
|
|
34
|
-
u = np.clip(np.asarray(policy_fn(obs), dtype=np.float64).reshape(N_ACT),
|
|
35
|
-
-1.0, 1.0)
|
|
36
|
-
acts[k] = u
|
|
37
|
-
theta = sim.step(u)
|
|
38
|
-
obs, _ = task.step(theta, u)
|
|
39
|
-
summ = task.tracker.summary()
|
|
40
|
-
a = wrap_to_pi(np.asarray(theta, dtype=np.float64) - np.pi)
|
|
41
|
-
return dict(t_can=float(summ["t_can"]),
|
|
42
|
-
success=float(summ["t_can"] >= 10.0),
|
|
43
|
-
ever_up=float(summ["ever_up"]),
|
|
44
|
-
first_up_t=float(summ["first_up_t"]),
|
|
45
|
-
ep_return=float(summ["ep_return"]),
|
|
46
|
-
final_ang_err=float(np.abs(a).max()),
|
|
47
|
-
up_frac=float(summ["up_frac"]),
|
|
48
|
-
mean_abs_action=float(np.mean(np.abs(acts))),
|
|
49
|
-
action_std=float(np.std(acts)),
|
|
50
|
-
mean_u1=float(acts[:, 0].mean()),
|
|
51
|
-
mean_u3=float(acts[:, 1].mean()),
|
|
52
|
-
steps=int(episode_steps))
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def evaluate_starts(policy_fn, p, starts, episode_steps=3000, reward_w=None,
|
|
56
|
-
obs_mode="full"):
|
|
57
|
-
"""Deterministic rollouts from an explicit list of (theta, omega) starts."""
|
|
58
|
-
rows = []
|
|
59
|
-
for theta0, omega0 in starts:
|
|
60
|
-
sim = PendulumSim(p, episode_steps=episode_steps)
|
|
61
|
-
task = NumpyTask(p, reward_w=reward_w, obs_mode=obs_mode)
|
|
62
|
-
theta = sim.reset(theta0, omega0)
|
|
63
|
-
obs = task.reset(theta)
|
|
64
|
-
for _ in range(episode_steps):
|
|
65
|
-
u = np.clip(np.asarray(policy_fn(obs), dtype=np.float64).reshape(N_ACT),
|
|
66
|
-
-1.0, 1.0)
|
|
67
|
-
theta = sim.step(u)
|
|
68
|
-
obs, _ = task.step(theta, u)
|
|
69
|
-
summ = task.tracker.summary()
|
|
70
|
-
a = wrap_to_pi(np.asarray(theta, dtype=np.float64) - np.pi)
|
|
71
|
-
rows.append(dict(t_can=float(summ["t_can"]),
|
|
72
|
-
success=float(summ["t_can"] >= 10.0),
|
|
73
|
-
ever_up=float(summ["ever_up"]),
|
|
74
|
-
ep_return=float(summ["ep_return"]),
|
|
75
|
-
final_ang_err=float(np.abs(a).max())))
|
|
76
|
-
tc = np.array([r["t_can"] for r in rows], dtype=np.float64)
|
|
77
|
-
su = np.array([r["success"] for r in rows], dtype=np.float64)
|
|
78
|
-
re = np.array([r["ep_return"] for r in rows], dtype=np.float64)
|
|
79
|
-
ae = np.array([r["final_ang_err"] for r in rows], dtype=np.float64)
|
|
80
|
-
eu = np.array([r["ever_up"] for r in rows], dtype=np.float64)
|
|
81
|
-
return dict(n=int(len(rows)),
|
|
82
|
-
success_rate=float(su.mean()) if len(rows) else 0.0,
|
|
83
|
-
ever_up_rate=float(eu.mean()) if len(rows) else 0.0,
|
|
84
|
-
mean_t_can=float(tc.mean()) if len(rows) else 0.0,
|
|
85
|
-
median_t_can=float(np.median(tc)) if len(rows) else 0.0,
|
|
86
|
-
min_t_can=float(tc.min()) if len(rows) else 0.0,
|
|
87
|
-
max_t_can=float(tc.max()) if len(rows) else 0.0,
|
|
88
|
-
mean_return=float(re.mean()) if len(rows) else 0.0,
|
|
89
|
-
mean_final_ang_err=float(ae.mean()) if len(rows) else 0.0,
|
|
90
|
-
rows=rows)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def make_ppo_policy(actor):
|
|
94
|
-
def policy(obs):
|
|
95
|
-
with torch.no_grad():
|
|
96
|
-
x = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0)
|
|
97
|
-
return actor(x).squeeze(0).numpy()
|
|
98
|
-
return policy
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def make_sac_policy(actor):
|
|
102
|
-
def policy(obs):
|
|
103
|
-
with torch.no_grad():
|
|
104
|
-
x = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0)
|
|
105
|
-
a, _ = actor.sample(x, det=True)
|
|
106
|
-
return a.squeeze(0).numpy()
|
|
107
|
-
return policy
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
"""Reconstruct results/<exp>.json for a finished run from its checkpoints,
|
|
2
|
-
TensorBoard event file and log. Useful when the trainer crashed after saving
|
|
3
|
-
checkpoints but before writing the JSON (e.g. the PPO robustness bug).
|
|
4
|
-
|
|
5
|
-
Usage:
|
|
6
|
-
python3 finalize_run.py --exp ppo_full_base_rand --algo ppo \
|
|
7
|
-
--obs full --reward base --init rand --envs 64 --minutes 45
|
|
8
|
-
|
|
9
|
-
The fixed-state eval and the 8-start robustness check are re-run here with the
|
|
10
|
-
same code as the trainers, so the numbers are real, not copied from the log.
|
|
11
|
-
The eval curve is parsed from logs/<exp>.log and the wall time from TB.
|
|
12
|
-
"""
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
import re
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
|
|
21
|
-
import torch
|
|
22
|
-
|
|
23
|
-
from evaluate import (evaluate, evaluate_starts, make_ppo_policy,
|
|
24
|
-
make_sac_policy)
|
|
25
|
-
from models import ActorCritic, SquashedGaussianActor
|
|
26
|
-
from obs import obs_spec
|
|
27
|
-
from sim import EPISODE_STEPS, Params
|
|
28
|
-
from task import get_reward_preset, sample_train_init
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def build_policy(ck, algo, obs_dim):
|
|
32
|
-
a = ck.get("args") or {}
|
|
33
|
-
hidden = int(a.get("hidden", 128 if algo == "ppo" else 256))
|
|
34
|
-
depth = int(a.get("depth", 2))
|
|
35
|
-
if algo == "ppo":
|
|
36
|
-
net = ActorCritic(obs_dim, act_dim=2, hidden=hidden, depth=depth)
|
|
37
|
-
net.load_state_dict(ck["model"])
|
|
38
|
-
return make_ppo_policy(net.actor), net
|
|
39
|
-
net = SquashedGaussianActor(obs_dim, 2, hidden=hidden, depth=depth)
|
|
40
|
-
net.load_state_dict(ck["model"])
|
|
41
|
-
return make_sac_policy(net), net
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def parse_curve(log_path):
|
|
45
|
-
rows = []
|
|
46
|
-
if not os.path.exists(log_path):
|
|
47
|
-
return rows
|
|
48
|
-
pat = re.compile(
|
|
49
|
-
r"iter\s+(\d+)\s+steps\s+([\d.]+)M.*?EVAL tc\s+([\d.]+)\s+"
|
|
50
|
-
r"succ\s+(\d+)\s+everup\s+(\d+)\s+angerr\s+([\d.]+)\s+"
|
|
51
|
-
r"ret\s+([-\d.]+)")
|
|
52
|
-
pat_sac = re.compile(
|
|
53
|
-
r"steps\s+([\d.]+)k\s+upd\s+(\d+).*?EVAL tc\s+([\d.]+)\s+"
|
|
54
|
-
r"succ\s+(\d+)\s+everup\s+(\d+)\s+angerr\s+([\d.]+)\s+"
|
|
55
|
-
r"ret\s+([-\d.]+)")
|
|
56
|
-
for line in open(log_path, errors="ignore"):
|
|
57
|
-
m = pat.search(line)
|
|
58
|
-
if m:
|
|
59
|
-
rows.append(dict(iter=int(m.group(1)),
|
|
60
|
-
global_steps=int(float(m.group(2)) * 1e6),
|
|
61
|
-
t_can=float(m.group(3)),
|
|
62
|
-
success=float(m.group(4)),
|
|
63
|
-
ever_up=float(m.group(5)),
|
|
64
|
-
final_ang_err=float(m.group(6)),
|
|
65
|
-
ep_return=float(m.group(7))))
|
|
66
|
-
continue
|
|
67
|
-
m = pat_sac.search(line)
|
|
68
|
-
if m:
|
|
69
|
-
rows.append(dict(global_steps=int(float(m.group(1)) * 1e3),
|
|
70
|
-
n_updates=int(m.group(2)),
|
|
71
|
-
t_can=float(m.group(3)),
|
|
72
|
-
success=float(m.group(4)),
|
|
73
|
-
ever_up=float(m.group(5)),
|
|
74
|
-
final_ang_err=float(m.group(6)),
|
|
75
|
-
ep_return=float(m.group(7))))
|
|
76
|
-
return rows
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def tb_last(tb_root, exp, tag):
|
|
80
|
-
from tensorboard.backend.event_processing.event_accumulator import (
|
|
81
|
-
EventAccumulator)
|
|
82
|
-
d = Path(tb_root) / exp
|
|
83
|
-
if not d.is_dir():
|
|
84
|
-
return None
|
|
85
|
-
ea = EventAccumulator(str(d), size_guidance={"scalars": 0})
|
|
86
|
-
ea.Reload()
|
|
87
|
-
if tag not in ea.Tags().get("scalars", []):
|
|
88
|
-
return None
|
|
89
|
-
return ea.Scalars(tag)[-1].value
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def main():
|
|
93
|
-
ap = argparse.ArgumentParser()
|
|
94
|
-
ap.add_argument("--exp", required=True)
|
|
95
|
-
ap.add_argument("--algo", required=True, choices=["ppo", "sac"])
|
|
96
|
-
ap.add_argument("--obs", required=True)
|
|
97
|
-
ap.add_argument("--reward", required=True)
|
|
98
|
-
ap.add_argument("--init", required=True)
|
|
99
|
-
ap.add_argument("--envs", type=int, required=True)
|
|
100
|
-
ap.add_argument("--minutes", type=float, required=True)
|
|
101
|
-
ap.add_argument("--seed", type=int, default=0)
|
|
102
|
-
ap.add_argument("--ckpt-dir", default="checkpoints")
|
|
103
|
-
ap.add_argument("--tb-root", default="tb")
|
|
104
|
-
ap.add_argument("--out", default="results")
|
|
105
|
-
ap.add_argument("--log-dir", default="logs")
|
|
106
|
-
ap.add_argument("--robust-starts", type=int, default=8)
|
|
107
|
-
args = ap.parse_args()
|
|
108
|
-
|
|
109
|
-
ck_final = torch.load(os.path.join(args.ckpt_dir, args.exp + "_final.pt"),
|
|
110
|
-
map_location="cpu")
|
|
111
|
-
ck_best = torch.load(os.path.join(args.ckpt_dir, args.exp + "_best.pt"),
|
|
112
|
-
map_location="cpu")
|
|
113
|
-
obs_dim = ck_final["obs_dim"]
|
|
114
|
-
spec = ck_final.get("obs") or obs_spec(args.obs)
|
|
115
|
-
reward_w = ck_final.get("reward") or get_reward_preset(args.reward)
|
|
116
|
-
p = Params()
|
|
117
|
-
pol_final, _ = build_policy(ck_final, args.algo, obs_dim)
|
|
118
|
-
pol_best, _ = build_policy(ck_best, args.algo, obs_dim)
|
|
119
|
-
ev_final = evaluate(pol_final, p, EPISODE_STEPS, reward_w=reward_w,
|
|
120
|
-
obs_mode=args.obs)
|
|
121
|
-
ev_best = evaluate(pol_best, p, EPISODE_STEPS, reward_w=reward_w,
|
|
122
|
-
obs_mode=args.obs)
|
|
123
|
-
|
|
124
|
-
g = torch.Generator().manual_seed(args.seed * 1000 + 123)
|
|
125
|
-
th, om = sample_train_init(args.robust_starts, gen=g)
|
|
126
|
-
starts = list(zip(th.numpy(), om.numpy()))
|
|
127
|
-
rob = evaluate_starts(pol_final, p, starts, EPISODE_STEPS,
|
|
128
|
-
reward_w=reward_w, obs_mode=args.obs)
|
|
129
|
-
rob_best = evaluate_starts(pol_best, p, starts, EPISODE_STEPS,
|
|
130
|
-
reward_w=reward_w, obs_mode=args.obs)
|
|
131
|
-
|
|
132
|
-
curve = parse_curve(os.path.join(args.log_dir, args.exp + ".log"))
|
|
133
|
-
elapsed = tb_last(args.tb_root, args.exp, "time/elapsed_min")
|
|
134
|
-
wall = float(elapsed) * 60.0 if elapsed is not None else 0.0
|
|
135
|
-
steps = ck_final.get("global_steps")
|
|
136
|
-
if not steps:
|
|
137
|
-
steps = tb_last(args.tb_root, args.exp, "train/global_steps")
|
|
138
|
-
out = dict(algo=args.algo, exp=args.exp, seed=args.seed,
|
|
139
|
-
envs=args.envs, obs=spec, obs_dim=obs_dim,
|
|
140
|
-
init=ck_final.get("init"), reward=reward_w,
|
|
141
|
-
reward_preset=args.reward, args=ck_final.get("args"),
|
|
142
|
-
minutes_budget=args.minutes, global_steps=int(steps or 0),
|
|
143
|
-
wall_time_s=wall,
|
|
144
|
-
steps_per_s=(float(steps) / wall) if wall > 0 else None,
|
|
145
|
-
final_eval=ev_final, best_eval=ev_best,
|
|
146
|
-
best_t_can=ev_best["t_can"], eval_curve=curve,
|
|
147
|
-
robust=rob, robust_best=rob_best,
|
|
148
|
-
checkpoints=dict(best=args.exp + "_best.pt",
|
|
149
|
-
last=args.exp + "_last.pt",
|
|
150
|
-
final=args.exp + "_final.pt"),
|
|
151
|
-
reconstructed=True,
|
|
152
|
-
reconstruct_note=("results JSON reconstructed by finalize_run.py "
|
|
153
|
-
"after the trainer crashed post-checkpoint; "
|
|
154
|
-
"fixed eval and robustness were re-run."))
|
|
155
|
-
with open(os.path.join(args.out, args.exp + ".json"), "w") as f:
|
|
156
|
-
json.dump(out, f, indent=2)
|
|
157
|
-
print(json.dumps(dict(exp=args.exp, steps=out["global_steps"],
|
|
158
|
-
wall_min=round(wall / 60.0, 1),
|
|
159
|
-
final_t_can=ev_final["t_can"],
|
|
160
|
-
best_t_can=ev_best["t_can"],
|
|
161
|
-
final_success=ev_final["success"],
|
|
162
|
-
best_success=ev_best["success"],
|
|
163
|
-
robust_final=rob["success_rate"],
|
|
164
|
-
robust_best=rob_best["success_rate"]), indent=2))
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if __name__ == "__main__":
|
|
168
|
-
main()
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
"""Networks: PPO actor-critic MLP, SAC squashed-Gaussian actor, twin Q.
|
|
2
|
-
|
|
3
|
-
PPO: 2x128 tanh, separate actor and critic, state-independent log_std capped
|
|
4
|
-
to [-2.3, -0.25] (the recipe of the sibling rounds).
|
|
5
|
-
SAC: 2x256 tanh actor with tanh-Gaussian head and a shared-trunk twin Q (two
|
|
6
|
-
output heads). Sharing the critic trunk keeps the CPU update affordable while
|
|
7
|
-
preserving the double-Q target.
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import math
|
|
12
|
-
|
|
13
|
-
import numpy as np
|
|
14
|
-
import torch
|
|
15
|
-
import torch.nn as nn
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class MLP(nn.Module):
|
|
19
|
-
def __init__(self, in_dim, hidden=128, depth=2, out_dim=1, out_gain=0.01):
|
|
20
|
-
super().__init__()
|
|
21
|
-
layers = []
|
|
22
|
-
d = in_dim
|
|
23
|
-
for _ in range(depth):
|
|
24
|
-
lin = nn.Linear(d, hidden)
|
|
25
|
-
nn.init.orthogonal_(lin.weight, gain=np.sqrt(2))
|
|
26
|
-
nn.init.zeros_(lin.bias)
|
|
27
|
-
layers += [lin, nn.Tanh()]
|
|
28
|
-
d = hidden
|
|
29
|
-
self.body = nn.Sequential(*layers)
|
|
30
|
-
self.out = nn.Linear(d, out_dim)
|
|
31
|
-
nn.init.orthogonal_(self.out.weight, gain=out_gain)
|
|
32
|
-
nn.init.zeros_(self.out.bias)
|
|
33
|
-
|
|
34
|
-
def forward(self, x):
|
|
35
|
-
return self.out(self.body(x))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# ------------------------------- PPO ---------------------------------------
|
|
39
|
-
LOG_STD_MIN, LOG_STD_MAX = -2.3, -0.25
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class ActorCritic(nn.Module):
|
|
43
|
-
def __init__(self, obs_dim, act_dim=2, hidden=128, depth=2):
|
|
44
|
-
super().__init__()
|
|
45
|
-
self.act_dim = act_dim
|
|
46
|
-
self.actor = MLP(obs_dim, hidden, depth, act_dim)
|
|
47
|
-
self.critic = MLP(obs_dim, hidden, depth, 1, out_gain=1.0)
|
|
48
|
-
self.log_std = nn.Parameter(torch.full((act_dim,), -0.7))
|
|
49
|
-
|
|
50
|
-
def dist(self, obs):
|
|
51
|
-
mean = self.actor(obs)
|
|
52
|
-
std = self.effective_log_std().exp().expand_as(mean)
|
|
53
|
-
return torch.distributions.Normal(mean, std)
|
|
54
|
-
|
|
55
|
-
def effective_log_std(self):
|
|
56
|
-
return self.log_std.clamp(LOG_STD_MIN, LOG_STD_MAX)
|
|
57
|
-
|
|
58
|
-
def value(self, obs):
|
|
59
|
-
return self.critic(obs).squeeze(-1)
|
|
60
|
-
|
|
61
|
-
def deterministic_action(self, obs):
|
|
62
|
-
return self.actor(obs)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ------------------------------- SAC ---------------------------------------
|
|
66
|
-
SAC_LOG_STD_MIN, SAC_LOG_STD_MAX = -5.0, 2.0
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class SquashedGaussianActor(nn.Module):
|
|
70
|
-
def __init__(self, in_dim, act_dim=2, hidden=256, depth=2):
|
|
71
|
-
super().__init__()
|
|
72
|
-
self.body = nn.Sequential()
|
|
73
|
-
d = in_dim
|
|
74
|
-
for _ in range(depth):
|
|
75
|
-
lin = nn.Linear(d, hidden)
|
|
76
|
-
nn.init.orthogonal_(lin.weight, gain=math.sqrt(2))
|
|
77
|
-
nn.init.zeros_(lin.bias)
|
|
78
|
-
self.body.extend([lin, nn.Tanh()])
|
|
79
|
-
d = hidden
|
|
80
|
-
self.mean = nn.Linear(hidden, act_dim)
|
|
81
|
-
self.log_std = nn.Linear(hidden, act_dim)
|
|
82
|
-
nn.init.orthogonal_(self.mean.weight, gain=0.01)
|
|
83
|
-
nn.init.zeros_(self.mean.bias)
|
|
84
|
-
nn.init.orthogonal_(self.log_std.weight, gain=0.01)
|
|
85
|
-
nn.init.zeros_(self.log_std.bias)
|
|
86
|
-
self.act_dim = act_dim
|
|
87
|
-
|
|
88
|
-
def forward(self, obs):
|
|
89
|
-
h = self.body(obs)
|
|
90
|
-
return self.mean(h), self.log_std(h).clamp(SAC_LOG_STD_MIN, SAC_LOG_STD_MAX)
|
|
91
|
-
|
|
92
|
-
def sample(self, obs, det=False):
|
|
93
|
-
if det:
|
|
94
|
-
return torch.tanh(self.mean(self.body(obs))), None
|
|
95
|
-
mean, log_std = self(obs)
|
|
96
|
-
std = log_std.exp()
|
|
97
|
-
eps = torch.randn_like(mean)
|
|
98
|
-
x = mean + std * eps
|
|
99
|
-
a = torch.tanh(x)
|
|
100
|
-
logp = (-0.5 * eps ** 2 - log_std - 0.5 * math.log(2 * math.pi)).sum(-1)
|
|
101
|
-
logp = logp - torch.log(1 - a ** 2 + 1e-6).sum(-1)
|
|
102
|
-
return a, logp
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class TwinQ(nn.Module):
|
|
106
|
-
"""Shared trunk, two Q heads; forward returns (B, 2)."""
|
|
107
|
-
|
|
108
|
-
def __init__(self, in_dim, act_dim=2, hidden=256, depth=2):
|
|
109
|
-
super().__init__()
|
|
110
|
-
self.body = nn.Sequential()
|
|
111
|
-
d = in_dim + act_dim
|
|
112
|
-
for _ in range(depth):
|
|
113
|
-
lin = nn.Linear(d, hidden)
|
|
114
|
-
nn.init.orthogonal_(lin.weight, gain=math.sqrt(2))
|
|
115
|
-
nn.init.zeros_(lin.bias)
|
|
116
|
-
self.body.extend([lin, nn.Tanh()])
|
|
117
|
-
d = hidden
|
|
118
|
-
self.q = nn.Linear(hidden, 2)
|
|
119
|
-
nn.init.orthogonal_(self.q.weight, gain=1.0)
|
|
120
|
-
nn.init.zeros_(self.q.bias)
|
|
121
|
-
|
|
122
|
-
def forward(self, obs, act):
|
|
123
|
-
x = torch.cat([obs, act], dim=-1)
|
|
124
|
-
return self.q(self.body(x))
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
"""Policy-side angle observation for the 3-link pendulum (switchable modes).
|
|
2
|
-
|
|
3
|
-
The simulator only exposes the three joint angles theta. The observation is
|
|
4
|
-
built entirely from that angle stream (no omega, no tip, no energy):
|
|
5
|
-
|
|
6
|
-
a_i(t) = wrap_to_pi(theta_i(t) - pi) (upright-relative)
|
|
7
|
-
da_i(t) = wrap_to_pi(a_i(t) - a_i(t-1)) / DT, da_i(0) = 0 at reset
|
|
8
|
-
|
|
9
|
-
frame(t) = [cos a1, sin a1, cos a2, sin a2, cos a3, sin a3,
|
|
10
|
-
da1/5, da2/5, da3/5] (9 dims, diff=True)
|
|
11
|
-
frame(t) = [cos a1, sin a1, cos a2, sin a2, cos a3, sin a3]
|
|
12
|
-
(6 dims, diff=False)
|
|
13
|
-
|
|
14
|
-
obs(t) = H frames, oldest first, flattened
|
|
15
|
-
|
|
16
|
-
Modes (--obs):
|
|
17
|
-
full H=4, diff=True -> 36 dims (the original observation)
|
|
18
|
-
angle H=4, diff=False -> 24 dims (cos/sin only, no manual velocity)
|
|
19
|
-
full_h1 H=1, diff=True -> 9 dims (single frame incl. da/5)
|
|
20
|
-
angle_h1 H=1, diff=False -> 6 dims (single frame cos/sin only)
|
|
21
|
-
full_h2 H=2, diff=True -> 18 dims (extra mode, optional)
|
|
22
|
-
angle_h2 H=2, diff=False -> 12 dims (extra mode, optional)
|
|
23
|
-
|
|
24
|
-
Even when diff=False the builder still computes da internally, because the
|
|
25
|
-
trainer-side reward uses it; the channel is only left out of the policy
|
|
26
|
-
observation. theta is used in radians exactly as produced by the simulator
|
|
27
|
-
(never divided by pi). The history is initialised by repeating the reset
|
|
28
|
-
frame H times.
|
|
29
|
-
"""
|
|
30
|
-
from __future__ import annotations
|
|
31
|
-
|
|
32
|
-
import numpy as np
|
|
33
|
-
|
|
34
|
-
DT = 0.01
|
|
35
|
-
H = 4
|
|
36
|
-
VEL_SCALE = 5.0
|
|
37
|
-
|
|
38
|
-
# mode -> (history length, whether the manual-difference velocity is observed)
|
|
39
|
-
OBS_MODES = {
|
|
40
|
-
"full": dict(h=4, diff=True),
|
|
41
|
-
"angle": dict(h=4, diff=False),
|
|
42
|
-
"full_h1": dict(h=1, diff=True),
|
|
43
|
-
"angle_h1": dict(h=1, diff=False),
|
|
44
|
-
"full_h2": dict(h=2, diff=True),
|
|
45
|
-
"angle_h2": dict(h=2, diff=False),
|
|
46
|
-
"angle_h8": dict(h=8, diff=False), # extra: can longer history replace da?
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def frame_dim(diff: bool) -> int:
|
|
51
|
-
return 9 if diff else 6
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def obs_spec(mode: str = "full") -> dict:
|
|
55
|
-
if mode not in OBS_MODES:
|
|
56
|
-
raise ValueError(f"unknown obs mode {mode!r}; choose from {sorted(OBS_MODES)}")
|
|
57
|
-
c = OBS_MODES[mode]
|
|
58
|
-
fd = frame_dim(c["diff"])
|
|
59
|
-
notes = ("cos/sin(a) + da/5, H=%d, a=wrap(theta-pi), da is the manual "
|
|
60
|
-
"finite difference, da(0)=0" % c["h"])
|
|
61
|
-
if not c["diff"]:
|
|
62
|
-
notes = ("cos/sin(a) only (NO velocity channel), H=%d, "
|
|
63
|
-
"a=wrap(theta-pi)" % c["h"])
|
|
64
|
-
return dict(mode=mode, enc="sincos", diff=bool(c["diff"]), h=int(c["h"]),
|
|
65
|
-
vel_scale=VEL_SCALE, dt=DT, frame_dim=fd,
|
|
66
|
-
dim=int(c["h"] * fd), notes=notes)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# Backwards-compatible constants for the original full observation.
|
|
70
|
-
FRAME_DIM = frame_dim(True)
|
|
71
|
-
OBS_DIM = obs_spec("full")["dim"]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def wrap_to_pi(a):
|
|
75
|
-
return (a + np.pi) % (2.0 * np.pi) - np.pi
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def frame_np(a, da, diff=True):
|
|
79
|
-
a = np.asarray(a, dtype=np.float64)
|
|
80
|
-
cs = np.empty(6, dtype=np.float64)
|
|
81
|
-
cs[0::2] = np.cos(a)
|
|
82
|
-
cs[1::2] = np.sin(a)
|
|
83
|
-
if not diff:
|
|
84
|
-
return cs
|
|
85
|
-
return np.concatenate([cs, np.asarray(da, dtype=np.float64) / VEL_SCALE])
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class NumpyAngleObs:
|
|
89
|
-
"""Float64 single-env builder. reset/update take theta only."""
|
|
90
|
-
|
|
91
|
-
def __init__(self, h: int = H, vel_scale: float = VEL_SCALE, dt: float = DT,
|
|
92
|
-
diff: bool = True):
|
|
93
|
-
self.h = int(h)
|
|
94
|
-
self.diff = bool(diff)
|
|
95
|
-
self.fdim = frame_dim(self.diff)
|
|
96
|
-
self.vel_scale = float(vel_scale)
|
|
97
|
-
self.dt = float(dt)
|
|
98
|
-
self.a_prev = np.zeros(3)
|
|
99
|
-
self.da = np.zeros(3)
|
|
100
|
-
self.frames = np.zeros((self.h, self.fdim))
|
|
101
|
-
self._obs = np.zeros(self.h * self.fdim, dtype=np.float32)
|
|
102
|
-
|
|
103
|
-
def _frame(self, a, da):
|
|
104
|
-
cs = np.empty(6, dtype=np.float64)
|
|
105
|
-
cs[0::2] = np.cos(a)
|
|
106
|
-
cs[1::2] = np.sin(a)
|
|
107
|
-
if not self.diff:
|
|
108
|
-
return cs
|
|
109
|
-
return np.concatenate([cs, da / self.vel_scale])
|
|
110
|
-
|
|
111
|
-
def _flatten(self):
|
|
112
|
-
self._obs = self.frames.reshape(-1).astype(np.float32)
|
|
113
|
-
return self._obs
|
|
114
|
-
|
|
115
|
-
def reset(self, theta):
|
|
116
|
-
a = wrap_to_pi(np.asarray(theta, dtype=np.float64).reshape(3) - np.pi)
|
|
117
|
-
self.a_prev = a.copy()
|
|
118
|
-
self.da = np.zeros(3)
|
|
119
|
-
f0 = self._frame(a, self.da)
|
|
120
|
-
self.frames = np.tile(f0, (self.h, 1))
|
|
121
|
-
return self._flatten()
|
|
122
|
-
|
|
123
|
-
@property
|
|
124
|
-
def obs(self):
|
|
125
|
-
return self._obs
|
|
126
|
-
|
|
127
|
-
def update(self, theta):
|
|
128
|
-
a = wrap_to_pi(np.asarray(theta, dtype=np.float64).reshape(3) - np.pi)
|
|
129
|
-
self.da = wrap_to_pi(a - self.a_prev) / self.dt
|
|
130
|
-
self.a_prev = a.copy()
|
|
131
|
-
f = self._frame(a, self.da)
|
|
132
|
-
if self.h > 1:
|
|
133
|
-
self.frames = np.roll(self.frames, -1, axis=0)
|
|
134
|
-
self.frames[-1] = f
|
|
135
|
-
return self._flatten()
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
class TorchAngleObs:
|
|
139
|
-
"""Batched float32 builder. reset/update take theta only."""
|
|
140
|
-
|
|
141
|
-
def __init__(self, n: int, h: int = H, vel_scale: float = VEL_SCALE,
|
|
142
|
-
dt: float = DT, device="cpu", diff: bool = True):
|
|
143
|
-
import torch
|
|
144
|
-
self.torch = torch
|
|
145
|
-
self.n = int(n)
|
|
146
|
-
self.h = int(h)
|
|
147
|
-
self.diff = bool(diff)
|
|
148
|
-
self.fdim = frame_dim(self.diff)
|
|
149
|
-
self.vel_scale = float(vel_scale)
|
|
150
|
-
self.dt = float(dt)
|
|
151
|
-
self.device = device
|
|
152
|
-
self.a_prev = torch.zeros(n, 3, device=device)
|
|
153
|
-
self.da = torch.zeros(n, 3, device=device)
|
|
154
|
-
self.frames = torch.zeros(n, self.h, self.fdim, device=device)
|
|
155
|
-
|
|
156
|
-
def _frame(self, a, da):
|
|
157
|
-
t = self.torch
|
|
158
|
-
cs = t.stack([t.cos(a), t.sin(a)], dim=2).reshape(a.shape[0], 6)
|
|
159
|
-
if not self.diff:
|
|
160
|
-
return cs
|
|
161
|
-
return t.cat([cs, da / self.vel_scale], dim=1)
|
|
162
|
-
|
|
163
|
-
def obs(self):
|
|
164
|
-
return self.frames.reshape(self.n, -1)
|
|
165
|
-
|
|
166
|
-
def reset(self, theta, idx=None):
|
|
167
|
-
t = self.torch
|
|
168
|
-
theta = t.as_tensor(theta, dtype=t.float32, device=self.device).reshape(-1, 3)
|
|
169
|
-
if idx is None:
|
|
170
|
-
a = t.remainder(theta - np.pi + np.pi, 2 * np.pi) - np.pi
|
|
171
|
-
self.a_prev = a.clone()
|
|
172
|
-
self.da = t.zeros_like(a)
|
|
173
|
-
f0 = self._frame(a, self.da)
|
|
174
|
-
self.frames = f0.unsqueeze(1).expand(-1, self.h, -1).clone()
|
|
175
|
-
return self.obs()
|
|
176
|
-
idx = t.as_tensor(idx, dtype=t.long, device=self.device).flatten()
|
|
177
|
-
a = t.remainder(theta - np.pi + np.pi, 2 * np.pi) - np.pi
|
|
178
|
-
self.a_prev[idx] = a
|
|
179
|
-
self.da[idx] = 0.0
|
|
180
|
-
f0 = self._frame(a, t.zeros_like(a))
|
|
181
|
-
self.frames[idx] = f0.unsqueeze(1).expand(-1, self.h, -1)
|
|
182
|
-
return self.obs()
|
|
183
|
-
|
|
184
|
-
def update(self, theta):
|
|
185
|
-
t = self.torch
|
|
186
|
-
theta = t.as_tensor(theta, dtype=t.float32, device=self.device).reshape(-1, 3)
|
|
187
|
-
a = t.remainder(theta - np.pi + np.pi, 2 * np.pi) - np.pi
|
|
188
|
-
self.da = (t.remainder(a - self.a_prev + np.pi, 2 * np.pi) - np.pi) / self.dt
|
|
189
|
-
self.a_prev = a.clone()
|
|
190
|
-
f = self._frame(a, self.da)
|
|
191
|
-
if self.h > 1:
|
|
192
|
-
self.frames = t.cat([self.frames[:, 1:], f.unsqueeze(1)], dim=1)
|
|
193
|
-
else:
|
|
194
|
-
self.frames[:, -1] = f
|
|
195
|
-
return self.obs()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def make_numpy_obs(mode: str = "full", **kw):
|
|
199
|
-
c = OBS_MODES[mode]
|
|
200
|
-
return NumpyAngleObs(h=c["h"], diff=c["diff"], **kw)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def make_torch_obs(n: int, mode: str = "full", **kw):
|
|
204
|
-
c = OBS_MODES[mode]
|
|
205
|
-
return TorchAngleObs(n, h=c["h"], diff=c["diff"], **kw)
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Plot selected eval t_can curves (PPO left, SAC right)."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import matplotlib
|
|
8
|
-
matplotlib.use("Agg")
|
|
9
|
-
import matplotlib.pyplot as plt
|
|
10
|
-
|
|
11
|
-
ROOT = Path(__file__).resolve().parent
|
|
12
|
-
RES = ROOT / "results"
|
|
13
|
-
|
|
14
|
-
PPO = ["ppo_full_base_rand", "ppo_full_base_rand_s1",
|
|
15
|
-
"ppo_full_base_rand_s2", "ppo_angle_base_rand", "ppo_angle_h8_rand",
|
|
16
|
-
"ppo_full_h1_rand", "ppo_full_h1_mix", "ppo_full_base_fixed"]
|
|
17
|
-
SAC = ["sac_full_base_rand", "sac_full_base_fixed", "sac_angle_base_rand"]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def load(exp):
|
|
21
|
-
p = RES / (exp + ".json")
|
|
22
|
-
if not p.exists():
|
|
23
|
-
return None
|
|
24
|
-
with open(p) as f:
|
|
25
|
-
return json.load(f)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def main():
|
|
29
|
-
fig, axes = plt.subplots(1, 2, figsize=(14, 5.0), sharey=True)
|
|
30
|
-
for ax, names, title in [(axes[0], PPO, "PPO"), (axes[1], SAC, "SAC")]:
|
|
31
|
-
for exp in names:
|
|
32
|
-
r = load(exp)
|
|
33
|
-
if not r:
|
|
34
|
-
continue
|
|
35
|
-
c = r.get("eval_curve") or []
|
|
36
|
-
if not c:
|
|
37
|
-
continue
|
|
38
|
-
x = [v["global_steps"] / 1e6 for v in c]
|
|
39
|
-
y = [v["t_can"] for v in c]
|
|
40
|
-
ax.plot(x, y, lw=1.3, label=f"{exp} (best {r['best_t_can']:.2f}s)")
|
|
41
|
-
ax.axhline(10.0, color="k", ls=":", lw=1)
|
|
42
|
-
ax.set_title(f"{title}: fixed-initial eval t_can")
|
|
43
|
-
ax.set_xlabel("environment steps (M)")
|
|
44
|
-
ax.set_ylabel("t_can (s)")
|
|
45
|
-
ax.grid(alpha=0.3)
|
|
46
|
-
ax.legend(fontsize=7)
|
|
47
|
-
fig.tight_layout()
|
|
48
|
-
out = RES / "curves_selected.png"
|
|
49
|
-
fig.savefig(out, dpi=130)
|
|
50
|
-
plt.close(fig)
|
|
51
|
-
print(out)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if __name__ == "__main__":
|
|
55
|
-
main()
|