ChirathD commited on
Commit
70664b2
·
verified ·
1 Parent(s): 34e6503

Add hdppo-InvertedDoublePendulum-v5 package (weights, code, model card)

Browse files
README.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: hd-ppo
3
+ tags:
4
+ - InvertedDoublePendulum-v5
5
+ - deep-reinforcement-learning
6
+ - reinforcement-learning
7
+ - hyperdimensional-computing
8
+ - fractional-power-encoding
9
+ - LTU-AI
10
+ model-index:
11
+ - name: Hybrid-HD-PPO
12
+ results:
13
+ - task:
14
+ type: reinforcement-learning
15
+ name: reinforcement-learning
16
+ dataset:
17
+ name: InvertedDoublePendulum-v5
18
+ type: InvertedDoublePendulum-v5
19
+ metrics:
20
+ - type: mean_reward
21
+ value: 9359.26
22
+ name: mean_reward
23
+ verified: false
24
+ ---
25
+
26
+ # **Hybrid-HD-PPO** Agent playing **InvertedDoublePendulum-v5**
27
+
28
+ This is a trained **Hybrid-HD-PPO** (Hyperdimensional Proximal Policy Optimization) agent
29
+ playing **InvertedDoublePendulum-v5** using **gradient-adaptive Fractional Power Encoding (FPE)**
30
+ with a prune-and-fine-tune pipeline.
31
+
32
+ Published by [LTU-AI](https://huggingface.co/LTU-AI).
33
+
34
+ ## Pipeline
35
+
36
+ 1. Train a teacher at **D=512** with gradient-adaptive single-beta FPE.
37
+ 2. Prune by actor-weight importance through **D=512 → 128 → 64**.
38
+ 3. Fine-tune each pruned checkpoint with PPO.
39
+
40
+ Published checkpoint: seed **7**, compact **D=64** model
41
+ (held-out eval mean reward **9359.26**).
42
+
43
+ ## Usage
44
+
45
+ Install dependencies:
46
+
47
+ ```bash
48
+ pip install -r requirements.txt
49
+ ```
50
+
51
+ Evaluate the local checkpoint:
52
+
53
+ ```bash
54
+ python enjoy.py --weights hdppo-InvertedDoublePendulum-v5/weights.npz --episodes 10
55
+ ```
56
+
57
+ Render episodes:
58
+
59
+ ```bash
60
+ python enjoy.py --weights hdppo-InvertedDoublePendulum-v5/weights.npz --render --episodes 3
61
+ ```
62
+
63
+ Record a replay video:
64
+
65
+ ```bash
66
+ python record_video.py --weights hdppo-InvertedDoublePendulum-v5/weights.npz --output replay.mp4
67
+ ```
68
+
69
+ Load from Hugging Face Hub:
70
+
71
+ ```bash
72
+ python enjoy.py --weights LTU-AI/hdppo-InvertedDoublePendulum-v5 --episodes 10
73
+ ```
74
+
75
+ ## Training pipeline
76
+
77
+ Reproduce the teacher → prune → fine-tune workflow:
78
+
79
+ ```bash
80
+ python train_hdppo.py
81
+ ```
82
+
83
+ ## Hyperparameters
84
+
85
+ ```python
86
+ {
87
+ "env": "InvertedDoublePendulum-v5",
88
+ "algo": "Hybrid-HD-PPO (HD actor + MLP critic, gradient-adaptive FPE)",
89
+ "teacher_D": 512,
90
+ "pruned_D": 64,
91
+ "timesteps_per_stage": 1000000,
92
+ "seed": 7
93
+ }
94
+ ```
95
+
96
+ ## Environment Arguments
97
+
98
+ ```python
99
+ {
100
+ "render_mode": "rgb_array"
101
+ }
102
+ ```
103
+
104
+ ## Model files
105
+
106
+ | File | Description |
107
+ |------|-------------|
108
+ | `hdppo-InvertedDoublePendulum-v5/weights.npz` | Published actor (+ critic if HD) and FPE encoder (D=64) |
109
+ | `hdppo-InvertedDoublePendulum-v5/weights_D512_teacher.npz` | Teacher checkpoint (D=512) |
110
+ | `results.json` | Evaluation summary for the published checkpoint |
111
+ | `results_D512_teacher.json` | Evaluation summary for the teacher |
112
+ | `config.yml` | Training hyperparameters |
113
+ | `train_hdppo.py` / training modules | Self-contained training code |
114
+
115
+ ## Citation
116
+
117
+ If you use this model, please cite the HD-PPO / Hybrid-HD-PPO work.
config.yml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ !!python/object/apply:collections.OrderedDict
2
+ -
3
+ - - env
4
+ - InvertedDoublePendulum-v5
5
+ - - algo
6
+ - Hybrid-HD-PPO (HD actor + MLP critic, gradient-adaptive FPE)
7
+ - - teacher_D
8
+ - 512
9
+ - - pruned_D
10
+ - 64
11
+ - - timesteps_per_stage
12
+ - 1000000
13
+ - - seed
14
+ - 7
enjoy.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import gymnasium as gym
4
+ import numpy as np
5
+ import torch
6
+ import train_hdppo as m
7
+
8
+ def _warmup_policy(encoder, actor, env_id, env_kwargs, n_steps=200):
9
+ env = gym.make(env_id, **env_kwargs)
10
+ obs, _ = env.reset(seed=0)
11
+ for i in range(n_steps):
12
+ Hr, Hi = encoder.encode(obs)
13
+ action = actor.greedy_action_np(Hr, Hi)
14
+ obs, _, term, trunc, _ = env.step(action)
15
+ if term or trunc:
16
+ obs, _ = env.reset(seed=i + 1)
17
+ env.close()
18
+
19
+ def load_policy_from_checkpoint(path, seed=42, warmup=True):
20
+ data = np.load(path)
21
+ cfg = dict(m.CONFIG)
22
+ cfg['D'] = int(data['D'])
23
+ cfg['beta'] = float(data['beta_base'])
24
+ cfg['fpe_phi_init'] = data['fpe_phi']
25
+ if 'feat_lo' in data:
26
+ cfg['feat_lo'] = data['feat_lo'].tolist()
27
+ cfg['feat_hi'] = data['feat_hi'].tolist()
28
+ torch.manual_seed(seed)
29
+ np.random.seed(seed)
30
+ encoder = m.make_encoder(cfg, seed)
31
+ actor = m.HDLinearActor(cfg['D'], cfg['action_dim'], cfg['log_std_init'], float(data['action_low']) if 'action_low' in data else cfg['action_low'], float(data['action_high']) if 'action_high' in data else cfg['action_high'])
32
+ with torch.no_grad():
33
+ actor.W_re.data.copy_(torch.from_numpy(np.asarray(data['W_actor_re'], dtype=np.float32)))
34
+ actor.W_im.data.copy_(torch.from_numpy(np.asarray(data['W_actor_im'], dtype=np.float32)))
35
+ if 'log_std' in data:
36
+ actor.log_std.data.copy_(torch.from_numpy(np.asarray(data['log_std'], dtype=np.float32)))
37
+ if cfg.get('adaptive_beta', True):
38
+ encoder.link_torch_actor(actor)
39
+ actor.eval()
40
+ if warmup:
41
+ _warmup_policy(encoder, actor, 'InvertedDoublePendulum-v5', dict(cfg.get('env_kwargs', {})))
42
+ return (encoder, actor, cfg)
43
+
44
+ def resolve_weights(weights_arg):
45
+ if os.path.isfile(weights_arg):
46
+ return weights_arg
47
+ try:
48
+ from huggingface_hub import hf_hub_download
49
+ except ImportError as exc:
50
+ raise SystemExit('Install huggingface_hub to load remote checkpoints: pip install huggingface_hub') from exc
51
+ return hf_hub_download(repo_id=weights_arg, filename='hdppo-InvertedDoublePendulum-v5/weights.npz')
52
+
53
+ def main():
54
+ parser = argparse.ArgumentParser(description='Enjoy Hybrid-HD-PPO on InvertedDoublePendulum-v5')
55
+ parser.add_argument('--weights', default='hdppo-InvertedDoublePendulum-v5/weights.npz')
56
+ parser.add_argument('--episodes', type=int, default=5)
57
+ parser.add_argument('--seed', type=int, default=10000)
58
+ parser.add_argument('--render', action='store_true')
59
+ args = parser.parse_args()
60
+ weights = resolve_weights(args.weights)
61
+ encoder, actor, cfg = load_policy_from_checkpoint(weights, seed=args.seed)
62
+ render_mode = 'human' if args.render else None
63
+ env_kwargs = dict(cfg.get('env_kwargs', {}))
64
+ if render_mode is not None:
65
+ env_kwargs['render_mode'] = render_mode
66
+ env = gym.make('InvertedDoublePendulum-v5', **env_kwargs)
67
+ rewards = []
68
+ for ep in range(args.episodes):
69
+ obs, _ = env.reset(seed=args.seed + ep)
70
+ done = False
71
+ ep_r = 0.0
72
+ while not done:
73
+ Hr, Hi = encoder.encode(obs)
74
+ action = actor.greedy_action_np(Hr, Hi)
75
+ obs, reward, term, trunc, _ = env.step(action)
76
+ ep_r += float(reward)
77
+ done = term or trunc
78
+ rewards.append(ep_r)
79
+ print(f'episode {ep + 1}: return={ep_r:.1f}')
80
+ env.close()
81
+ arr = np.asarray(rewards, dtype=np.float64)
82
+ print(f'mean={arr.mean():.2f} std={(arr.std(ddof=1) if len(arr) > 1 else 0.0):.2f}')
83
+ if __name__ == '__main__':
84
+ main()
env_kwargs.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ render_mode: rgb_array
hdppo-InvertedDoublePendulum-v5/weights.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aeed64d704edab4833d005fcf923491b8646c310f140231a6a0228a8770ba2fe
3
+ size 6442
hdppo-InvertedDoublePendulum-v5/weights_D512_teacher.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c29adfac5cded935dc6966504cd701a1c07be71da232c7af63ce3c83d9b6e411
3
+ size 26154
record_video.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import argparse
3
+ import os
4
+
5
+ import gymnasium as gym
6
+ import imageio.v2 as imageio
7
+
8
+ from enjoy import load_policy_from_checkpoint
9
+
10
+
11
+ def record(weights_path, output_path, seed=42, max_steps=1000):
12
+ encoder, actor, cfg = load_policy_from_checkpoint(weights_path, seed=seed)
13
+ env_kwargs = dict(cfg.get("env_kwargs", {}))
14
+ env_kwargs["render_mode"] = "rgb_array"
15
+ env = gym.make("InvertedDoublePendulum-v5", **env_kwargs)
16
+ frames = []
17
+ obs, _ = env.reset(seed=seed)
18
+ for _ in range(max_steps):
19
+ Hr, Hi = encoder.encode(obs)
20
+ action = actor.greedy_action_np(Hr, Hi)
21
+ obs, reward, term, trunc, _ = env.step(action)
22
+ frames.append(env.render())
23
+ if term or trunc:
24
+ break
25
+
26
+ env.close()
27
+ os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
28
+ imageio.mimsave(output_path, frames, fps=30)
29
+ return len(frames)
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(description="Record InvertedDoublePendulum-v5 replay video")
34
+ parser.add_argument("--weights", default="hdppo-InvertedDoublePendulum-v5/weights.npz")
35
+ parser.add_argument("--output", default="replay.mp4")
36
+ parser.add_argument("--seed", type=int, default=42)
37
+ parser.add_argument("--max-steps", type=int, default=1000)
38
+ args = parser.parse_args()
39
+ n = record(args.weights, args.output, seed=args.seed, max_steps=args.max_steps)
40
+ print(f"saved {n} frames to {args.output}")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gymnasium[mujoco]>=0.29.0
2
+ mujoco>=3.0.0
3
+ numpy>=1.24.0
4
+ torch>=2.0.0
5
+ psutil>=5.9.0
6
+ imageio>=2.31.0
7
+ imageio-ffmpeg>=0.4.9
8
+ huggingface_hub>=0.20.0
results.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mean_reward": 9359.2568359375,
3
+ "eval_mean_best": 9359.2412109375,
4
+ "is_deterministic": true,
5
+ "D": 64,
6
+ "seed": 7,
7
+ "algo": "Hybrid-HD-PPO",
8
+ "env": "InvertedDoublePendulum-v5",
9
+ "beta_base": 0.7071067690849304,
10
+ "beta_bands": null
11
+ }
results_D512_teacher.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mean_reward": 2338.0703125,
3
+ "eval_mean_best": 2338.070556640625,
4
+ "is_deterministic": true,
5
+ "D": 512,
6
+ "seed": 7,
7
+ "algo": "Hybrid-HD-PPO",
8
+ "env": "InvertedDoublePendulum-v5",
9
+ "beta_base": 0.7071067690849304,
10
+ "beta_bands": null
11
+ }
train_hdppo.py ADDED
@@ -0,0 +1,721 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import csv
2
+ import json
3
+ import os
4
+ import sys
5
+ if sys.platform.startswith('linux'):
6
+ os.environ.setdefault('MUJOCO_GL', 'egl')
7
+ os.environ.setdefault('PYOPENGL_PLATFORM', 'egl')
8
+ import time
9
+ import multiprocessing as mp
10
+ from contextlib import contextmanager
11
+ from collections import deque
12
+ import numpy as np
13
+ import gymnasium as gym
14
+ import psutil
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ ENV_ID = 'InvertedDoublePendulum-v5'
19
+ N_EVAL_EPISODES = 20
20
+ EVAL_SEED_BASE = 10000
21
+ LOG_2PI_HALF = 0.5 * np.log(2.0 * np.pi)
22
+ DEVICE = torch.device('cpu')
23
+ torch.set_num_threads(max(1, psutil.cpu_count(logical=False) or 2))
24
+
25
+ def state_features(obs):
26
+ return np.asarray(obs, dtype=np.float32)
27
+ N_WORKERS = max(1, min(4, os.cpu_count() or 2))
28
+ ROLLOUT_STEPS = 2048
29
+ OBS_DIM = 9
30
+ ACTION_DIM = 1
31
+ ENV_KWARGS = dict()
32
+ CONFIG = dict(env_id=ENV_ID, env_kwargs=ENV_KWARGS, feat_lo=[-10.0] * OBS_DIM, feat_hi=[10.0] * OBS_DIM, feature_fn=state_features, n_feat=OBS_DIM, action_dim=ACTION_DIM, action_low=-1.0, action_high=1.0, D=512, beta=2.0 ** (-0.5), adaptive_beta=True, rollout_steps=ROLLOUT_STEPS, n_workers=N_WORKERS, clip_eps=0.2, vf_coef=0.5, entropy_coef=0.001, entropy_decay=1.0, entropy_min=0.0, gamma=0.99, lam=0.95, adv_clip=5.0, actor_lr=0.0003, critic_lr=0.001, log_std_lr=0.0003, n_epochs=10, minibatch_size=256, max_grad_norm=0.5, target_kl=None, log_std_init=-0.5, log_std_min=-2.0, log_std_max=2.0, critic_hidden=(64, 64), ema_interval=200, ema_alpha=0.15)
33
+ DEFAULT_SEED = 42
34
+ BETA_EFF_MIN_MULT = 0.2
35
+ BETA_EFF_MAX_MULT = 4.0
36
+ G_EMA_DECAY = 0.99
37
+
38
+ class HDEncoderGradAdaptive:
39
+
40
+ def __init__(self, feat_lo, feat_hi, D, seed, feature_fn, beta_base, phi_init=None):
41
+ self.lo = np.array(feat_lo, np.float32)
42
+ self.hi = np.array(feat_hi, np.float32)
43
+ self.feature_fn = feature_fn
44
+ self.beta_base = float(beta_base)
45
+ self.D = D
46
+ self.n_feat = len(feat_lo)
47
+ self.sqrtD = float(np.sqrt(D))
48
+ if phi_init is not None:
49
+ self.Phi = np.asarray(phi_init, dtype=np.float32)
50
+ else:
51
+ rng = np.random.default_rng(seed)
52
+ self.Phi = rng.uniform(-np.pi, np.pi, (self.n_feat, D)).astype(np.float32)
53
+ scale = 2.0 / (self.hi - self.lo + 1e-08)
54
+ self.dtheta_ds_unit = self.Phi * scale[:, None]
55
+ self.W_re = None
56
+ self.W_im = None
57
+ self._torch_actor = None
58
+ self._log_g_ema = 0.0
59
+ self.beta_eff_history = []
60
+
61
+ def set_weights(self, W_re, W_im):
62
+ self.W_re = W_re
63
+ self.W_im = W_im
64
+
65
+ def link_torch_actor(self, actor):
66
+ self._torch_actor = actor
67
+
68
+ def _current_weights(self):
69
+ if self._torch_actor is not None:
70
+ return (self._torch_actor.W_re.detach().cpu().numpy(), self._torch_actor.W_im.detach().cpu().numpy())
71
+ return (self.W_re, self.W_im)
72
+
73
+ @property
74
+ def beta_vec(self):
75
+ return np.full(self.D, self.beta_base, dtype=np.float32)
76
+
77
+ def encode(self, state):
78
+ W_re, W_im = self._current_weights()
79
+ s = self.feature_fn(state)
80
+ s = np.clip(s, self.lo, self.hi)
81
+ s_norm = (2.0 * (s - self.lo) / (self.hi - self.lo + 1e-08) - 1.0).astype(np.float32)
82
+ proj = s_norm @ self.Phi
83
+ theta_base = self.beta_base * proj
84
+ H_re_base, H_im_base = (np.cos(theta_base), np.sin(theta_base))
85
+ dtheta_ds = self.beta_base * self.dtheta_ds_unit
86
+ dH_re_ds = -H_im_base[None, :] * dtheta_ds
87
+ dH_im_ds = H_re_base[None, :] * dtheta_ds
88
+ J = (dH_re_ds @ W_re + dH_im_ds @ W_im) / self.sqrtD
89
+ g = float(np.linalg.norm(J))
90
+ log_g = np.log1p(g)
91
+ centered = log_g - self._log_g_ema
92
+ self._log_g_ema = G_EMA_DECAY * self._log_g_ema + (1.0 - G_EMA_DECAY) * log_g
93
+ beta_eff = self.beta_base * (1.0 + centered)
94
+ beta_eff = float(np.clip(beta_eff, self.beta_base * BETA_EFF_MIN_MULT, self.beta_base * BETA_EFF_MAX_MULT))
95
+ self.beta_eff_history.append(beta_eff)
96
+ theta = beta_eff * proj
97
+ return (np.cos(theta).astype(np.float32), np.sin(theta).astype(np.float32))
98
+
99
+ class HDEncoderFixed:
100
+
101
+ def __init__(self, feat_lo, feat_hi, D, beta, seed, feature_fn, phi_init=None):
102
+ self.lo = np.array(feat_lo, np.float32)
103
+ self.hi = np.array(feat_hi, np.float32)
104
+ self.feature_fn = feature_fn
105
+ self.D = D
106
+ self.beta_vec = np.full(D, float(beta), dtype=np.float32)
107
+ self.n_feat = len(feat_lo)
108
+ if phi_init is not None:
109
+ self.Phi = np.asarray(phi_init, dtype=np.float32)
110
+ else:
111
+ rng = np.random.default_rng(seed)
112
+ self.Phi = rng.uniform(-np.pi, np.pi, (self.n_feat, D)).astype(np.float32)
113
+
114
+ def encode(self, state):
115
+ s = self.feature_fn(state)
116
+ s = np.clip(s, self.lo, self.hi)
117
+ s_norm = (2.0 * (s - self.lo) / (self.hi - self.lo + 1e-08) - 1.0).astype(np.float32)
118
+ theta = s_norm @ self.Phi * self.beta_vec
119
+ return (np.cos(theta), np.sin(theta))
120
+
121
+ def make_encoder(cfg, seed):
122
+ if cfg.get('adaptive_beta', True):
123
+ return HDEncoderGradAdaptive(cfg['feat_lo'], cfg['feat_hi'], cfg['D'], seed, cfg['feature_fn'], cfg['beta'], phi_init=cfg.get('fpe_phi_init'))
124
+ return HDEncoderFixed(cfg['feat_lo'], cfg['feat_hi'], cfg['D'], cfg['beta'], seed, cfg['feature_fn'], phi_init=cfg.get('fpe_phi_init'))
125
+
126
+ class HDLinearActor(nn.Module):
127
+
128
+ def __init__(self, D, action_dim, log_std_init, action_low, action_high):
129
+ super().__init__()
130
+ self.D = D
131
+ self.action_dim = action_dim
132
+ self.sqrtD = float(np.sqrt(D))
133
+ self.W_re = nn.Parameter(torch.zeros(D, action_dim, dtype=torch.float32))
134
+ self.W_im = nn.Parameter(torch.zeros(D, action_dim, dtype=torch.float32))
135
+ self.log_std = nn.Parameter(torch.full((action_dim,), float(log_std_init), dtype=torch.float32))
136
+ self.a_lo = float(action_low)
137
+ self.a_hi = float(action_high)
138
+
139
+ def mean_from_hd(self, H_re, H_im):
140
+ return (H_re @ self.W_re + H_im @ self.W_im) / self.sqrtD
141
+
142
+ def dist(self, H_re, H_im):
143
+ mu = self.mean_from_hd(H_re, H_im)
144
+ std = self.log_std.exp().unsqueeze(0).expand_as(mu)
145
+ return (mu, std)
146
+
147
+ @torch.no_grad()
148
+ def greedy_action_np(self, H_re_np, H_im_np):
149
+ Hr = torch.from_numpy(H_re_np).unsqueeze(0)
150
+ Hi = torch.from_numpy(H_im_np).unsqueeze(0)
151
+ mu = self.mean_from_hd(Hr, Hi).squeeze(0).cpu().numpy()
152
+ return np.clip(mu, self.a_lo, self.a_hi).astype(np.float32)
153
+
154
+ class MLPCritic(nn.Module):
155
+
156
+ def __init__(self, n_obs, hidden=(64, 64)):
157
+ super().__init__()
158
+ layers = []
159
+ last = n_obs
160
+ for h in hidden:
161
+ lin = nn.Linear(last, h)
162
+ nn.init.orthogonal_(lin.weight, gain=np.sqrt(2))
163
+ nn.init.zeros_(lin.bias)
164
+ layers += [lin, nn.Tanh()]
165
+ last = h
166
+ head = nn.Linear(last, 1)
167
+ nn.init.orthogonal_(head.weight, gain=1.0)
168
+ nn.init.zeros_(head.bias)
169
+ layers.append(head)
170
+ self.net = nn.Sequential(*layers)
171
+
172
+ def forward(self, obs):
173
+ return self.net(obs).squeeze(-1)
174
+
175
+ def compute_gae_with_trunc(rewards, values, next_values, terminated, truncated, last_value, gamma, lam):
176
+ n = rewards.shape[0]
177
+ adv = np.zeros(n, dtype=np.float64)
178
+ gae = 0.0
179
+ for t in range(n - 1, -1, -1):
180
+ if terminated[t]:
181
+ next_v = 0.0
182
+ elif truncated[t]:
183
+ next_v = next_values[t]
184
+ elif t == n - 1:
185
+ next_v = last_value
186
+ else:
187
+ next_v = values[t + 1]
188
+ delta = rewards[t] + gamma * next_v - values[t]
189
+ gae = delta if terminated[t] or truncated[t] else delta + gamma * lam * gae
190
+ adv[t] = gae
191
+ return (adv, adv + values)
192
+
193
+ class SystemMonitor:
194
+
195
+ def __init__(self):
196
+ self.proc = psutil.Process(os.getpid())
197
+
198
+ def _tree_memory_mb(self):
199
+ total = self.proc.memory_info().rss
200
+ for child in self.proc.children(recursive=True):
201
+ try:
202
+ total += child.memory_info().rss
203
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
204
+ pass
205
+ return total / 1024 ** 2
206
+
207
+ def snapshot(self):
208
+ return {'ram_process_tree_mb': self._tree_memory_mb()}
209
+
210
+ def rollout_worker(worker_id, cfg, conn, master_seed):
211
+ encoder = make_encoder(cfg, master_seed)
212
+ adaptive = cfg.get('adaptive_beta', True)
213
+ beta_log_writer = None
214
+ if adaptive and cfg.get('adaptive_beta_log_dir'):
215
+ log_dir = cfg['adaptive_beta_log_dir']
216
+ os.makedirs(log_dir, exist_ok=True)
217
+ beta_log_file = open(os.path.join(log_dir, f'worker_{worker_id}_beta_log.csv'), 'w', newline='')
218
+ beta_log_writer = csv.writer(beta_log_file)
219
+ beta_log_writer.writerow(['rollout_idx', 'n', 'mean', 'std', 'min', 'max'])
220
+ rollout_idx = 0
221
+ sqrtD = float(np.sqrt(cfg['D']))
222
+ D = cfg['D']
223
+ n_obs = cfg['n_feat']
224
+ action_dim = cfg['action_dim']
225
+ a_lo = float(cfg['action_low'])
226
+ a_hi = float(cfg['action_high'])
227
+ rng = np.random.default_rng(master_seed + 1 + worker_id * 1000)
228
+ env = gym.make(cfg['env_id'], **cfg.get('env_kwargs', {}))
229
+ state, _ = env.reset(seed=master_seed + 1 + worker_id * 1000)
230
+ while True:
231
+ cmd = conn.recv()
232
+ if cmd[0] == 'exit':
233
+ if beta_log_writer is not None:
234
+ beta_log_file.close()
235
+ env.close()
236
+ return
237
+ _, W_re, W_im, log_std, n_steps = cmd
238
+ if adaptive:
239
+ encoder.set_weights(W_re, W_im)
240
+ std = np.exp(log_std).astype(np.float32)
241
+ log_std_sum = float(log_std.sum())
242
+ H_res = np.empty((n_steps, D), np.float32)
243
+ H_ims = np.empty((n_steps, D), np.float32)
244
+ obs_arr = np.empty((n_steps, n_obs), np.float32)
245
+ nobs_arr = np.empty((n_steps, n_obs), np.float32)
246
+ a_arr = np.empty((n_steps, action_dim), np.float32)
247
+ r_arr = np.empty(n_steps, np.float64)
248
+ lp_arr = np.empty(n_steps, np.float32)
249
+ term_arr = np.empty(n_steps, np.float64)
250
+ trunc_arr = np.empty(n_steps, np.float64)
251
+ ep_rewards = []
252
+ ep_lengths = []
253
+ ep_r = 0.0
254
+ ep_len = 0
255
+ for t in range(n_steps):
256
+ H_re, H_im = encoder.encode(state)
257
+ mu = (H_re @ W_re + H_im @ W_im) / sqrtD
258
+ act = mu + std * rng.standard_normal(action_dim).astype(np.float32)
259
+ act = np.clip(act, a_lo, a_hi)
260
+ diff = act - mu
261
+ lp = float(-0.5 * np.sum((diff / std) ** 2) - log_std_sum - action_dim * LOG_2PI_HALF)
262
+ next_s, reward, term, trunc, _ = env.step(act.astype(np.float32))
263
+ H_res[t] = H_re
264
+ H_ims[t] = H_im
265
+ obs_arr[t] = np.asarray(state, dtype=np.float32)
266
+ nobs_arr[t] = np.asarray(next_s, dtype=np.float32)
267
+ a_arr[t] = act
268
+ r_arr[t] = float(reward)
269
+ lp_arr[t] = np.float32(lp)
270
+ term_arr[t] = float(term)
271
+ trunc_arr[t] = float(trunc)
272
+ ep_r += float(reward)
273
+ ep_len += 1
274
+ if term or trunc:
275
+ ep_rewards.append(ep_r)
276
+ ep_lengths.append(ep_len)
277
+ ep_r = 0.0
278
+ ep_len = 0
279
+ state, _ = env.reset()
280
+ else:
281
+ state = next_s
282
+ if beta_log_writer is not None:
283
+ beta_hist = np.asarray(encoder.beta_eff_history[-n_steps:], dtype=np.float64)
284
+ beta_log_writer.writerow([rollout_idx, len(beta_hist), float(beta_hist.mean()), float(beta_hist.std()), float(beta_hist.min()), float(beta_hist.max())])
285
+ beta_log_file.flush()
286
+ rollout_idx += 1
287
+ conn.send((H_res, H_ims, obs_arr, nobs_arr, a_arr, r_arr, lp_arr, term_arr, trunc_arr, ep_rewards, ep_lengths, state.copy(), bool(term or trunc)))
288
+
289
+ class WorkerPool:
290
+
291
+ def __init__(self, n_workers, cfg, master_seed):
292
+ self.n_workers = n_workers
293
+ self.conns = []
294
+ self.procs = []
295
+ ctx = mp.get_context('fork')
296
+ for i in range(n_workers):
297
+ parent_conn, child_conn = ctx.Pipe()
298
+ p = ctx.Process(target=rollout_worker, args=(i, cfg, child_conn, master_seed), daemon=True)
299
+ p.start()
300
+ child_conn.close()
301
+ self.conns.append(parent_conn)
302
+ self.procs.append(p)
303
+
304
+ def collect(self, W_re, W_im, log_std, total_steps):
305
+ steps_each = total_steps // self.n_workers
306
+ for conn in self.conns:
307
+ conn.send(('rollout', W_re.copy(), W_im.copy(), log_std.copy(), steps_each))
308
+ return [conn.recv() for conn in self.conns]
309
+
310
+ def close(self):
311
+ for conn in self.conns:
312
+ try:
313
+ conn.send(('exit',))
314
+ except Exception:
315
+ pass
316
+ for p in self.procs:
317
+ p.join(timeout=3)
318
+ if p.is_alive():
319
+ p.terminate()
320
+
321
+ def actor_snapshot(actor):
322
+ return {k: v.detach().clone() for k, v in actor.state_dict().items()}
323
+
324
+ def actor_restore(actor, snap, alpha):
325
+ sd = actor.state_dict()
326
+ with torch.no_grad():
327
+ for k in sd:
328
+ sd[k].mul_(1.0 - alpha).add_(snap[k], alpha=alpha)
329
+
330
+ @contextmanager
331
+ def actor_weights_swapped(actor, snap):
332
+ saved = actor_snapshot(actor)
333
+ actor_restore(actor, snap, alpha=1.0)
334
+ try:
335
+ yield
336
+ finally:
337
+ actor_restore(actor, saved, alpha=1.0)
338
+
339
+ def evaluate_agent(encoder, actor, env_id, env_kwargs, n_episodes=N_EVAL_EPISODES, seed_base=EVAL_SEED_BASE):
340
+ env = gym.make(env_id, **env_kwargs)
341
+ rewards = np.empty(n_episodes, dtype=np.float64)
342
+ actor.eval()
343
+ for i in range(n_episodes):
344
+ state, _ = env.reset(seed=seed_base + i)
345
+ ep_r = 0.0
346
+ done = False
347
+ while not done:
348
+ H_re, H_im = encoder.encode(state)
349
+ a = actor.greedy_action_np(H_re, H_im)
350
+ state, reward, term, trunc, _ = env.step(a)
351
+ ep_r += float(reward)
352
+ done = term or trunc
353
+ rewards[i] = ep_r
354
+ actor.train()
355
+ env.close()
356
+ n = len(rewards)
357
+ sem = float(rewards.std(ddof=1) / np.sqrt(n)) if n > 1 else 0.0
358
+ return dict(mean_reward=float(rewards.mean()), ci95_reward=1.96 * sem, rewards=rewards.tolist())
359
+
360
+ class HDPPOHybridAgent:
361
+
362
+ def __init__(self, cfg, seed=DEFAULT_SEED):
363
+ D = cfg['D']
364
+ torch.manual_seed(seed)
365
+ np.random.seed(seed)
366
+ self.actor = HDLinearActor(D, cfg['action_dim'], cfg['log_std_init'], cfg['action_low'], cfg['action_high']).to(DEVICE)
367
+ self.encoder = make_encoder(cfg, seed)
368
+ if cfg.get('adaptive_beta', True):
369
+ self.encoder.link_torch_actor(self.actor)
370
+ self.critic = MLPCritic(cfg['n_feat'], cfg['critic_hidden']).to(DEVICE)
371
+ self.opt_actor = torch.optim.Adam([{'params': [self.actor.W_re, self.actor.W_im], 'lr': cfg['actor_lr']}, {'params': [self.actor.log_std], 'lr': cfg['log_std_lr']}])
372
+ self.opt_critic = torch.optim.Adam(self.critic.parameters(), lr=cfg['critic_lr'])
373
+ self.cfg = cfg
374
+ self.entropy_coef = cfg['entropy_coef']
375
+ self.best_avg100 = -np.inf
376
+ self.best_snap = None
377
+ self.pool = WorkerPool(cfg['n_workers'], cfg, seed)
378
+
379
+ def collect_and_update(self, total_steps):
380
+ cfg = self.cfg
381
+ t_rollout_start = time.time()
382
+ with torch.no_grad():
383
+ W_re_np = self.actor.W_re.detach().cpu().numpy().astype(np.float32)
384
+ W_im_np = self.actor.W_im.detach().cpu().numpy().astype(np.float32)
385
+ log_std_np = self.actor.log_std.detach().cpu().numpy().astype(np.float32)
386
+ results = self.pool.collect(W_re_np, W_im_np, log_std_np, total_steps)
387
+ t_rollout = time.time() - t_rollout_start
388
+ t_update_start = time.time()
389
+ all_H_re, all_H_im, all_obs, all_nobs = ([], [], [], [])
390
+ all_a, all_lp, all_r, all_term, all_trunc = ([], [], [], [], [])
391
+ ep_rews, ep_lens = ([], [])
392
+ for H_re, H_im, obs, nobs, a, r, lp, term, trunc, ep_r, ep_l, ls, ld in results:
393
+ all_H_re.append(H_re)
394
+ all_H_im.append(H_im)
395
+ all_obs.append(obs)
396
+ all_nobs.append(nobs)
397
+ all_a.append(a)
398
+ all_lp.append(lp)
399
+ all_r.append(r)
400
+ all_term.append(term)
401
+ all_trunc.append(trunc)
402
+ ep_rews.extend(ep_r)
403
+ ep_lens.extend(ep_l)
404
+ H_re = np.concatenate(all_H_re)
405
+ H_im = np.concatenate(all_H_im)
406
+ obs_np = np.concatenate(all_obs)
407
+ nobs_np = np.concatenate(all_nobs)
408
+ a_np = np.concatenate(all_a).astype(np.float32)
409
+ old_lps = np.concatenate(all_lp).astype(np.float32)
410
+ r_np = np.concatenate(all_r)
411
+ term_np = np.concatenate(all_term)
412
+ trunc_np = np.concatenate(all_trunc)
413
+ self.critic.eval()
414
+ with torch.no_grad():
415
+ obs_t = torch.from_numpy(obs_np).to(DEVICE)
416
+ nobs_t = torch.from_numpy(nobs_np).to(DEVICE)
417
+ v_pred = self.critic(obs_t).cpu().numpy().astype(np.float64)
418
+ v_next = self.critic(nobs_t).cpu().numpy().astype(np.float64)
419
+ self.critic.train()
420
+ adv_list, ret_list = ([], [])
421
+ cursor = 0
422
+ for (_, _, _, _, _, _, _, _, _, _, _, ls, ld), worker_r in zip(results, all_r):
423
+ n = worker_r.shape[0]
424
+ r_seg = r_np[cursor:cursor + n]
425
+ v_seg = v_pred[cursor:cursor + n]
426
+ nv_seg = v_next[cursor:cursor + n]
427
+ term_seg = term_np[cursor:cursor + n]
428
+ trunc_seg = trunc_np[cursor:cursor + n]
429
+ if ld:
430
+ last_val = 0.0
431
+ else:
432
+ with torch.no_grad():
433
+ ls_t = torch.from_numpy(np.asarray(ls, dtype=np.float32)).unsqueeze(0).to(DEVICE)
434
+ last_val = float(self.critic(ls_t).item())
435
+ adv, ret = compute_gae_with_trunc(r_seg.astype(np.float64), v_seg.astype(np.float64), nv_seg.astype(np.float64), term_seg.astype(np.float64), trunc_seg.astype(np.float64), last_val, cfg['gamma'], cfg['lam'])
436
+ adv_list.append(adv)
437
+ ret_list.append(ret)
438
+ cursor += n
439
+ adv_np = np.concatenate(adv_list)
440
+ ret_np = np.concatenate(ret_list)
441
+ T = len(adv_np)
442
+ adv_std = float(adv_np.std())
443
+ adv_clip = float(cfg['adv_clip'])
444
+ adv_n_np = np.clip((adv_np - adv_np.mean()) / (adv_std + 1e-08), -adv_clip, adv_clip) if adv_std > 0.0001 else np.zeros(T, np.float64)
445
+ var_y = float(np.var(ret_np))
446
+ explained_var = float(1 - np.var(ret_np - v_pred) / var_y) if var_y > 1e-08 else 0.0
447
+ H_re_t = torch.from_numpy(H_re).to(DEVICE)
448
+ H_im_t = torch.from_numpy(H_im).to(DEVICE)
449
+ obs_t = torch.from_numpy(obs_np).to(DEVICE)
450
+ a_t = torch.from_numpy(a_np).to(DEVICE)
451
+ old_lp_t = torch.from_numpy(old_lps).to(DEVICE)
452
+ adv_t = torch.from_numpy(adv_n_np.astype(np.float32)).to(DEVICE)
453
+ ret_t = torch.from_numpy(ret_np.astype(np.float32)).to(DEVICE)
454
+ clip_eps = float(cfg['clip_eps'])
455
+ vf_coef = float(cfg['vf_coef'])
456
+ ent_coef = float(self.entropy_coef)
457
+ mb_size = int(cfg['minibatch_size'])
458
+ max_gn = float(cfg['max_grad_norm'])
459
+ log_std_min = float(cfg['log_std_min'])
460
+ log_std_max = float(cfg['log_std_max'])
461
+ action_dim = cfg['action_dim']
462
+ rng = np.random.default_rng()
463
+ kls, clip_fracs, actor_losses, critic_losses = ([], [], [], [])
464
+ for epoch in range(cfg['n_epochs']):
465
+ perm = rng.permutation(T)
466
+ for start in range(0, T, mb_size):
467
+ idx = perm[start:start + mb_size]
468
+ idx_t = torch.from_numpy(idx).to(DEVICE)
469
+ Hr_b = H_re_t.index_select(0, idx_t)
470
+ Hi_b = H_im_t.index_select(0, idx_t)
471
+ obs_b = obs_t.index_select(0, idx_t)
472
+ a_b = a_t.index_select(0, idx_t)
473
+ oldlp_b = old_lp_t.index_select(0, idx_t)
474
+ adv_b = adv_t.index_select(0, idx_t)
475
+ ret_b = ret_t.index_select(0, idx_t)
476
+ mu, std = self.actor.dist(Hr_b, Hi_b)
477
+ var = std * std
478
+ new_lp = (-0.5 * (a_b - mu).pow(2) / var - self.actor.log_std.unsqueeze(0) - LOG_2PI_HALF).sum(dim=-1)
479
+ ratio = (new_lp - oldlp_b).exp()
480
+ surr1 = ratio * adv_b
481
+ surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * adv_b
482
+ policy_loss = -torch.min(surr1, surr2).mean()
483
+ entropy = 0.5 * np.log(2 * np.pi * np.e) * action_dim + self.actor.log_std.sum()
484
+ actor_loss = policy_loss - ent_coef * entropy
485
+ self.opt_actor.zero_grad(set_to_none=True)
486
+ actor_loss.backward()
487
+ nn.utils.clip_grad_norm_([self.actor.W_re, self.actor.W_im, self.actor.log_std], max_gn)
488
+ self.opt_actor.step()
489
+ with torch.no_grad():
490
+ self.actor.log_std.clamp_(log_std_min, log_std_max)
491
+ v_b = self.critic(obs_b)
492
+ value_loss = F.mse_loss(v_b, ret_b)
493
+ self.opt_critic.zero_grad(set_to_none=True)
494
+ (vf_coef * value_loss).backward()
495
+ nn.utils.clip_grad_norm_(self.critic.parameters(), max_gn)
496
+ self.opt_critic.step()
497
+ with torch.no_grad():
498
+ log_ratio = new_lp - oldlp_b
499
+ approx_kl = (log_ratio.exp() - 1 - log_ratio).mean().item()
500
+ clip_frac = ((ratio > 1 + clip_eps) | (ratio < 1 - clip_eps)).float().mean().item()
501
+ kls.append(approx_kl)
502
+ clip_fracs.append(clip_frac)
503
+ actor_losses.append(policy_loss.item())
504
+ critic_losses.append(value_loss.item())
505
+ self.entropy_coef = max(self.entropy_coef * cfg['entropy_decay'], cfg['entropy_min'])
506
+ t_update = time.time() - t_update_start
507
+ with torch.no_grad():
508
+ log_std_now = self.actor.log_std.detach().cpu().numpy()
509
+ diag = dict(rollout_s=t_rollout, update_s=t_update, fps=total_steps / (t_rollout + t_update + 1e-08), explained_var=explained_var, policy_loss=float(np.mean(actor_losses)), value_loss=float(np.mean(critic_losses)), approx_kl=float(np.mean(kls)), clip_fraction=float(np.mean(clip_fracs)), entropy_coef=float(self.entropy_coef), log_std_mean=float(log_std_now.mean()), std_mean=float(np.exp(log_std_now).mean()), mean_value=float(v_pred.mean()))
510
+ return (ep_rews, ep_lens, diag)
511
+
512
+ def maybe_snapshot(self, avg100):
513
+ if avg100 > self.best_avg100:
514
+ self.best_avg100 = avg100
515
+ self.best_snap = actor_snapshot(self.actor)
516
+
517
+ def ema_restore(self):
518
+ if self.best_snap is not None:
519
+ actor_restore(self.actor, self.best_snap, self.cfg['ema_alpha'])
520
+
521
+ def evaluate_final(self, n_episodes=N_EVAL_EPISODES, seed_base=EVAL_SEED_BASE):
522
+ return evaluate_agent(self.encoder, self.actor, self.cfg['env_id'], self.cfg.get('env_kwargs', {}), n_episodes, seed_base)
523
+
524
+ def evaluate_best(self, n_episodes=N_EVAL_EPISODES, seed_base=EVAL_SEED_BASE):
525
+ if self.best_snap is None:
526
+ return None
527
+ with actor_weights_swapped(self.actor, self.best_snap):
528
+ return evaluate_agent(self.encoder, self.actor, self.cfg['env_id'], self.cfg.get('env_kwargs', {}), n_episodes, seed_base)
529
+
530
+ def close(self):
531
+ self.pool.close()
532
+
533
+ def train(total_timesteps, seed=DEFAULT_SEED, verbose=True, warm_start=None, save_weights_path=None, log_csv_path=None, eval_csv_path=None, eval_every_n_steps=None):
534
+ csv_file = csv_writer = None
535
+ if log_csv_path is not None:
536
+ csv_file = open(log_csv_path, 'w', newline='')
537
+ csv_writer = csv.writer(csv_file)
538
+ csv_writer.writerow(['global_step', 'wall_time_sec', 'episode', 'episodes_this_update', 'ep_rew_mean', 'ep_rew_max', 'ep_rew_min', 'ep_len_mean', 'best_avg100', 'policy_loss', 'value_loss', 'explained_var', 'approx_kl', 'clip_fraction', 'entropy_coef', 'log_std_mean', 'std_mean', 'mean_value', 'rollout_s', 'update_s', 'fps', 'ram_process_tree_mb'])
539
+ eval_csv_file = eval_csv_writer = None
540
+ if eval_csv_path is not None:
541
+ eval_csv_file = open(eval_csv_path, 'w', newline='')
542
+ eval_csv_writer = csv.writer(eval_csv_file)
543
+ eval_csv_writer.writerow(['global_step', 'eval_mean', 'eval_ci95', 'tag'])
544
+ cfg = dict(CONFIG) if warm_start is not None else CONFIG
545
+ if warm_start is not None:
546
+ cfg['D'] = int(warm_start['D'])
547
+ cfg['beta'] = warm_start.get('beta', cfg['beta'])
548
+ cfg['fpe_phi_init'] = np.asarray(warm_start['fpe_phi'], dtype=np.float32)
549
+ agent = HDPPOHybridAgent(cfg, seed=seed)
550
+ if warm_start is not None:
551
+ with torch.no_grad():
552
+ agent.actor.W_re.copy_(torch.from_numpy(np.asarray(warm_start['W_actor_re'], dtype=np.float32)))
553
+ agent.actor.W_im.copy_(torch.from_numpy(np.asarray(warm_start['W_actor_im'], dtype=np.float32)))
554
+ if 'log_std' in warm_start:
555
+ agent.actor.log_std.copy_(torch.from_numpy(np.asarray(warm_start['log_std'], dtype=np.float32)))
556
+ if verbose:
557
+ print(f' Warm-started from provided checkpoint: D={cfg['D']}')
558
+ if warm_start.get('critic_state_dict') is not None:
559
+ agent.critic.load_state_dict(warm_start['critic_state_dict'])
560
+ if verbose:
561
+ print(' Warm-started critic from provided checkpoint (warm-start, not reset)')
562
+ if eval_csv_writer is not None:
563
+ post_prune_eval = agent.evaluate_final()
564
+ eval_csv_writer.writerow([0, post_prune_eval['mean_reward'], post_prune_eval['ci95_reward'], 'post_prune'])
565
+ eval_csv_file.flush()
566
+ if verbose:
567
+ print(f' Post-prune eval (before fine-tuning): {post_prune_eval['mean_reward']:+.1f} +/- {post_prune_eval['ci95_reward']:.1f}')
568
+ next_eval_at = eval_every_n_steps
569
+ rollout_steps = cfg['rollout_steps']
570
+ ema_interval = cfg['ema_interval']
571
+ sysmon = SystemMonitor()
572
+ recent = deque(maxlen=100)
573
+ len_recent = deque(maxlen=100)
574
+ ep = 0
575
+ total_steps = 0
576
+ update_count = 0
577
+ if verbose:
578
+ print('=' * 80)
579
+ print(f'HD-PPO -> {cfg['env_id']}')
580
+ print(f' D={cfg['D']} beta={cfg['beta']} n_workers={cfg['n_workers']} total_timesteps={total_timesteps:,}')
581
+ print('=' * 80)
582
+ t0 = time.perf_counter()
583
+ while total_steps < total_timesteps:
584
+ ep_batch, ep_len_batch, diag = agent.collect_and_update(rollout_steps)
585
+ total_steps += rollout_steps
586
+ update_count += 1
587
+ wall_sec = int(time.perf_counter() - t0)
588
+ if eval_csv_writer is not None and next_eval_at is not None:
589
+ while total_steps >= next_eval_at:
590
+ periodic_eval = agent.evaluate_final()
591
+ eval_csv_writer.writerow([next_eval_at, periodic_eval['mean_reward'], periodic_eval['ci95_reward'], 'periodic'])
592
+ eval_csv_file.flush()
593
+ if verbose:
594
+ print(f' [eval @ step {next_eval_at:>9,}] {periodic_eval['mean_reward']:+.1f} +/- {periodic_eval['ci95_reward']:.1f}')
595
+ next_eval_at += eval_every_n_steps
596
+ for r, L in zip(ep_batch, ep_len_batch):
597
+ ep += 1
598
+ recent.append(r)
599
+ len_recent.append(L)
600
+ if len(recent) > 0:
601
+ avg100 = float(np.mean(recent))
602
+ agent.maybe_snapshot(avg100)
603
+ if ep % ema_interval == 0 and ep > 0:
604
+ agent.ema_restore()
605
+ if csv_writer is not None:
606
+ sysm = sysmon.snapshot()
607
+ csv_writer.writerow([total_steps, wall_sec, ep, len(ep_batch), avg100, float(np.max(recent)), float(np.min(recent)), float(np.mean(len_recent)) if len_recent else 0.0, agent.best_avg100, diag['policy_loss'], diag['value_loss'], diag['explained_var'], diag['approx_kl'], diag['clip_fraction'], diag['entropy_coef'], diag['log_std_mean'], diag['std_mean'], diag['mean_value'], diag['rollout_s'], diag['update_s'], diag['fps'], sysm['ram_process_tree_mb']])
608
+ csv_file.flush()
609
+ if verbose and update_count % 5 == 0:
610
+ sysm = sysmon.snapshot()
611
+ print(f' [step {total_steps:>9,}] ep {ep:>5} avg100={avg100:>+8.1f} best={agent.best_avg100:>+8.1f} pol_loss={diag['policy_loss']:>+.3f} v_loss={diag['value_loss']:>.2f} fps={diag['fps']:>6.0f} ram={sysm['ram_process_tree_mb']:>5.0f}MB')
612
+ if csv_file is not None:
613
+ csv_file.close()
614
+ total_time = time.perf_counter() - t0
615
+ final_train_avg = float(np.mean(list(recent))) if recent else 0.0
616
+ if verbose:
617
+ print(f'\n Running held-out eval ({N_EVAL_EPISODES} eps)...', flush=True)
618
+ eval_final = agent.evaluate_final()
619
+ eval_best = agent.evaluate_best()
620
+ if eval_csv_file is not None:
621
+ eval_csv_file.close()
622
+ critic_state_dict = {k: v.detach().clone() for k, v in agent.critic.state_dict().items()}
623
+ if save_weights_path is not None:
624
+ if agent.best_snap is not None:
625
+ best_sd = agent.best_snap
626
+ W_re_save = best_sd['W_re'].cpu().numpy()
627
+ W_im_save = best_sd['W_im'].cpu().numpy()
628
+ log_std_save = best_sd['log_std'].cpu().numpy()
629
+ else:
630
+ W_re_save = agent.actor.W_re.detach().cpu().numpy()
631
+ W_im_save = agent.actor.W_im.detach().cpu().numpy()
632
+ log_std_save = agent.actor.log_std.detach().cpu().numpy()
633
+ np.savez(save_weights_path, W_actor_re=W_re_save, W_actor_im=W_im_save, log_std=log_std_save, fpe_phi=agent.encoder.Phi, beta_base=np.float32(cfg['beta']), feat_lo=np.array(cfg['feat_lo'], dtype=np.float32), feat_hi=np.array(cfg['feat_hi'], dtype=np.float32), D=np.int32(cfg['D']), n_feat=np.int32(cfg['n_feat']), action_dim=np.int32(cfg['action_dim']), action_low=np.float32(cfg['action_low']), action_high=np.float32(cfg['action_high']), eval_mean_final=np.float32(eval_final['mean_reward']), eval_mean_best=np.float32(eval_best['mean_reward'] if eval_best is not None else np.nan))
634
+ if verbose:
635
+ print(f' Saved actor -> {save_weights_path} ({os.path.getsize(save_weights_path) / 1024:.1f} KB)')
636
+ if verbose:
637
+ eb_str = f'{eval_best['mean_reward']:.1f}+/-{eval_best['ci95_reward']:.1f}' if eval_best is not None else '-'
638
+ print(f'\n Training time: {total_time:.1f}s')
639
+ print(f' Episodes: {ep:,}')
640
+ print(f' Final train avg100: {final_train_avg:+.1f}')
641
+ print(f' Best train avg100: {agent.best_avg100:+.1f}')
642
+ print(f' Eval (final wts): {eval_final['mean_reward']:+.1f} +/- {eval_final['ci95_reward']:.1f}')
643
+ print(f' Eval (best wts): {eb_str}')
644
+ agent.close()
645
+ return dict(eval_mean_final=eval_final['mean_reward'], eval_ci95_final=eval_final['ci95_reward'], eval_mean_best=eval_best['mean_reward'] if eval_best is not None else None, final_avg100=final_train_avg, best_avg100=float(agent.best_avg100), training_time_sec=total_time, critic_state_dict=critic_state_dict)
646
+
647
+ def prune_actor_global(checkpoint, D_prime):
648
+ W_re, W_im = (np.asarray(checkpoint['W_actor_re']), np.asarray(checkpoint['W_actor_im']))
649
+ Phi = np.asarray(checkpoint['fpe_phi'])
650
+ beta_base = float(checkpoint['beta_base'])
651
+ importance = np.sqrt((W_re ** 2).sum(axis=1) + (W_im ** 2).sum(axis=1))
652
+ keep_idx = np.sort(np.argsort(-importance)[:D_prime])
653
+ return dict(D=D_prime, beta=beta_base, fpe_phi=Phi[:, keep_idx], W_actor_re=W_re[keep_idx], W_actor_im=W_im[keep_idx])
654
+ THIS_DIR = os.path.dirname(os.path.abspath(__file__))
655
+ SEED = DEFAULT_SEED
656
+ STAGES = [(512, 1000000), (128, 1000000), (64, 1000000)]
657
+ OUT_DIR = os.path.join(THIS_DIR, 'prune_finetune')
658
+
659
+ def weights_path(D, stage_label):
660
+ return os.path.join(OUT_DIR, f'hdppo_D{D}_{stage_label}.npz')
661
+
662
+ def curve_csv_path(D, stage_label):
663
+ return os.path.join(OUT_DIR, f'training_curve_D{D}_{stage_label}.csv')
664
+
665
+ def eval_csv_path_for(D, stage_label):
666
+ return os.path.join(OUT_DIR, f'eval_curve_D{D}_{stage_label}.csv')
667
+
668
+ def main():
669
+ os.makedirs(OUT_DIR, exist_ok=True)
670
+ results_json = os.path.join(OUT_DIR, 'results.json')
671
+ table_txt = os.path.join(OUT_DIR, 'results_table.txt')
672
+ stage_records = []
673
+ prev_path = None
674
+ prev_critic_sd = None
675
+ t_chain0 = time.perf_counter()
676
+ for i, (D, timesteps) in enumerate(STAGES):
677
+ if i == 0:
678
+ stage_label = 'teacher_fresh'
679
+ warm_start = None
680
+ print(f'\n{'#' * 90}\nSTAGE {i + 1}/{len(STAGES)}: D={D} FRESH, {timesteps:,} steps\n{'#' * 90}', flush=True)
681
+ else:
682
+ stage_label = 'finetuned'
683
+ prev_D = STAGES[i - 1][0]
684
+ print(f'\n{'#' * 90}\nSTAGE {i + 1}/{len(STAGES)}: prune D={prev_D} -> D={D}, then fine-tune {timesteps:,} steps (critic warm-started)\n{'#' * 90}', flush=True)
685
+ prev_ckpt = np.load(prev_path)
686
+ warm_start = prune_actor_global(prev_ckpt, D_prime=D)
687
+ warm_start['critic_state_dict'] = prev_critic_sd
688
+ print(f' Pruned: kept top-{D}/{prev_D} dimensions by weight importance ({prev_D / D:.1f}x cut)')
689
+ path = weights_path(D, stage_label)
690
+ curve_csv = curve_csv_path(D, stage_label)
691
+ eval_csv = eval_csv_path_for(D, stage_label)
692
+ t0 = time.time()
693
+ result = train(total_timesteps=timesteps, seed=SEED, verbose=True, warm_start=warm_start, save_weights_path=path, log_csv_path=curve_csv, eval_csv_path=eval_csv, eval_every_n_steps=50000)
694
+ wall = time.time() - t0
695
+ print(f' STAGE {i + 1} done: D={D} eval_final={result['eval_mean_final']:+.1f} eval_best={result['eval_mean_best']:+.1f} wall={wall:.0f}s', flush=True)
696
+ stage_records.append(dict(stage=stage_label, D=D, configured_timesteps=timesteps, weights_path=path, curve_csv=curve_csv, eval_csv=eval_csv, eval_mean_final=result['eval_mean_final'], eval_mean_best=result['eval_mean_best'], final_avg100=result['final_avg100'], best_avg100=result['best_avg100'], wall_time_sec=wall))
697
+ with open(results_json, 'w') as f:
698
+ json.dump(dict(seed=SEED, stages=stage_records, complete=False), f, indent=2)
699
+ prev_path = path
700
+ prev_critic_sd = result['critic_state_dict']
701
+ total_time = time.perf_counter() - t_chain0
702
+ print('\n' + '=' * 90)
703
+ header = f'{'stage':<16} {'D':>6} {'steps':>10} {'eval_final':>12} {'eval_best':>12} {'wall (min)':>11}'
704
+ print(header)
705
+ lines_txt = [header]
706
+ for r in stage_records:
707
+ line = f'{r['stage']:<16} {r['D']:>6} {r['configured_timesteps']:>10,} {r['eval_mean_final']:>12.1f} {r['eval_mean_best']:>12.1f} {r['wall_time_sec'] / 60:>11.1f}'
708
+ print(line)
709
+ lines_txt.append(line)
710
+ print(f'\nTotal chain wall time: {total_time / 60:.1f} min')
711
+ print('=' * 90)
712
+ lines_txt.append(f'\nTotal chain wall time: {total_time / 60:.1f} min')
713
+ with open(table_txt, 'w') as f:
714
+ f.write('\n'.join(lines_txt) + '\n')
715
+ print(f'\nWrote {table_txt}')
716
+ with open(results_json, 'w') as f:
717
+ json.dump(dict(seed=SEED, stages=stage_records, complete=True, total_chain_time_sec=total_time), f, indent=2)
718
+ print(f'Wrote {results_json}')
719
+ if __name__ == '__main__':
720
+ mp.set_start_method('fork', force=True)
721
+ main()