File size: 16,212 Bytes
d8290eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""Independent H2 GPU learner, based on 4fa48dd combined-action residual TD3."""
from __future__ import annotations

import copy
import json
import os
from pathlib import Path
import queue
import random
import signal
import time
import traceback

import numpy as np
import torch
from torch import nn

from .action import ResidualActionAdapter
from .config import RLConfig
from .data import ACTOR_INPUT_DIM, OBS_DIM, FORMAT, REWARD_PROTOCOL, ActionContract, EpisodeBatch
from .models import Actor, Critic, RunningNormalizer, flatten_parameters
from .replay import ReplayBuffer


def load_checkpoint(path, contract: ActionContract | None = None):
    # Checkpoints are local trusted training artifacts, never received over ZMQ.
    saved = torch.load(Path(path), map_location="cpu", weights_only=False)
    if not isinstance(saved, dict) or saved.get("format") != FORMAT:
        raise ValueError("requires H2 real residual v3 checkpoint; old H2/G1 checkpoints are unsupported")
    if saved.get("reward_protocol") != REWARD_PROTOCOL:
        raise ValueError("checkpoint reward protocol mismatch")
    if contract is not None:
        contract.validate_saved(saved.get("contract"))
    RLConfig(**saved["config"])
    return saved


class TD3Learner:
    def __init__(self, config: RLConfig, contract: ActionContract, *, device=None):
        self.config, self.contract = config, contract
        # CPU is a dependency-injection seam for tests; the process entrypoint
        # always supplies config.learner_device and never falls back from CUDA.
        self.device = torch.device(device or config.learner_device)
        if self.device.type == "cuda" and not torch.cuda.is_available():
            raise RuntimeError("H2 residual learner requires CUDA")
        random.seed(config.seed)
        np.random.seed(config.seed)
        torch.manual_seed(config.seed)
        self.actor = Actor().to(self.device)
        self.actor_target = copy.deepcopy(self.actor).eval()
        self.critics = [Critic().to(self.device), Critic().to(self.device)]
        self.critic_targets = [copy.deepcopy(c).eval() for c in self.critics]
        options = dict(betas=config.optimizer_betas, eps=config.optimizer_eps, weight_decay=config.weight_decay)
        self.actor_optimizer = torch.optim.AdamW(self.actor.parameters(), lr=config.actor_learning_rate, **options)
        self.critic_optimizers = [torch.optim.AdamW(c.parameters(), lr=config.critic_learning_rate, **options)
                                  for c in self.critics]
        self.normalizer = RunningNormalizer(ACTOR_INPUT_DIM, min_std=config.normalizer_min_std,
                                            clip=config.normalizer_clip)
        self.adapter = ResidualActionAdapter(contract)
        self.replay = ReplayBuffer(config.replay_capacity, config.seed)
        self.balls = self.warmup_balls = self.online_balls = self.transitions = 0
        self.critic_updates = self.actor_updates = self.warmup_updates = self.online_updates = 0
        self.pending_updates = self.policy_version = 0
        self.collector_rng_state = None
        self.last_summary = {}
        self.metrics = {}

    @property
    def warmup_complete(self):
        return (self.warmup_balls == self.config.warmup_balls and
                self.warmup_updates >= self.config.critic_warmup_steps and self.normalizer.frozen)

    def ingest(self, episode: EpisodeBatch):
        episode.validate()
        if episode.sequence != self.balls + 1:
            raise ValueError("episode sequence gap; refusing partial/misordered training")
        expected = "warmup" if self.warmup_balls < self.config.warmup_balls else "online"
        if episode.phase != expected or (expected == "online" and not self.warmup_complete):
            raise ValueError("episode training phase mismatch")
        if self.online_balls >= self.config.online_balls:
            raise ValueError("training ball budget exceeded")
        if expected == "warmup":
            for item in episode.transitions:
                self.normalizer.update(np.concatenate((item.base_obs, item.base_action)))
            self.warmup_balls += 1
            if self.warmup_balls == self.config.warmup_balls:
                self.normalizer.freeze()
        else:
            self.online_balls += 1
            self.pending_updates += len(episode.transitions) * self.config.critic_updates_per_step
        self.replay.add_episode(episode, self.config.n_step, self.config.discount)
        self.balls += 1
        self.transitions += len(episode.transitions)
        self.collector_rng_state = episode.collector_rng_state
        self.last_summary = dict(episode.summary, sent_to_learner=True)

    def _soft_update(self, source, target):
        with torch.no_grad():
            for src, dst in zip(source.parameters(), target.parameters()):
                dst.lerp_(src, self.config.target_tau)

    def update_once(self, *, critic_only=False):
        if self.replay.size < self.config.batch_size:
            raise RuntimeError("valid warmup balls did not produce a complete replay batch")
        c = self.config
        batch = {k: torch.as_tensor(v, device=self.device) for k, v in self.replay.sample(c.batch_size).items()}
        norm = self.normalizer.normalize_tensor
        inputs = norm(torch.cat((batch["base_obs"], batch["combined_action"]), dim=1))
        with torch.no_grad():
            next_inputs = norm(torch.cat((batch["next_base_obs"], batch["next_base_action"]), dim=1))
            residual = self.actor_target(next_inputs)
            if not critic_only:
                noise = (torch.randn_like(residual) * c.target_noise).clamp(-c.target_noise_clip, c.target_noise_clip)
                residual = (residual + noise).clamp(-1, 1)
            combined = self.adapter.combine_tensor(batch["next_base_action"], residual)
            target_inputs = norm(torch.cat((batch["next_base_obs"], combined), dim=1))
            q = torch.minimum(*(target(target_inputs) for target in self.critic_targets))
            target = batch["reward_n"] + batch["discount_n"] * batch["bootstrap_mask"] * q
        losses = []
        for critic, optimizer in zip(self.critics, self.critic_optimizers):
            loss = nn.functional.mse_loss(critic(inputs), target)
            if not torch.isfinite(loss):
                raise RuntimeError("nonfinite critic loss")
            optimizer.zero_grad(set_to_none=True)
            loss.backward()
            nn.utils.clip_grad_norm_(critic.parameters(), c.gradient_clip, error_if_nonfinite=True)
            optimizer.step()
            losses.append(float(loss.detach().cpu()))
        self.critic_updates += 1
        if critic_only:
            self.warmup_updates += 1
            for critic, target_net in zip(self.critics, self.critic_targets):
                self._soft_update(critic, target_net)
        else:
            self.online_updates += 1
            self.pending_updates -= 1
            if self.online_updates % c.actor_update_delay == 0:
                for critic in self.critics:
                    critic.requires_grad_(False)
                actor_inputs = torch.cat((batch["base_obs"], batch["base_action"]), dim=1)
                residual = self.actor(norm(actor_inputs))
                action = self.adapter.combine_tensor(actor_inputs[:, OBS_DIM:], residual)
                q_loss = -self.critics[0](norm(torch.cat((batch["base_obs"], action), dim=1))).mean()
                regularization = c.actor_residual_l2 * residual.square().mean()
                actor_loss = q_loss + regularization
                if not torch.isfinite(actor_loss):
                    raise RuntimeError("nonfinite actor loss")
                self.actor_optimizer.zero_grad(set_to_none=True)
                actor_loss.backward()
                nn.utils.clip_grad_norm_(self.actor.parameters(), c.gradient_clip, error_if_nonfinite=True)
                self.actor_optimizer.step()
                for critic in self.critics:
                    critic.requires_grad_(True)
                self._soft_update(self.actor, self.actor_target)
                for critic, target_net in zip(self.critics, self.critic_targets):
                    self._soft_update(critic, target_net)
                self.actor_updates += 1
                self.metrics["actor_loss"] = float(actor_loss.detach().cpu())
                self.metrics["actor_q_loss"] = float(q_loss.detach().cpu())
                self.metrics["actor_regularization"] = float(regularization.detach().cpu())
        self.metrics.update(critic_1_loss=losses[0], critic_2_loss=losses[1])

    def publish(self, sync):
        self.policy_version = sync.publish(flatten_parameters(self.actor), self.normalizer.mean,
                                           self.normalizer.std)

    def state_dict(self):
        counters = {name: getattr(self, name) for name in (
            "balls", "warmup_balls", "online_balls", "transitions", "critic_updates", "actor_updates",
            "warmup_updates", "online_updates", "pending_updates", "policy_version")}
        return dict(format=FORMAT, reward_protocol=REWARD_PROTOCOL,
                    source_commit="4fa48dd2672bd52d9349fb3f7902755d665aa725",
                    config=self.config.to_dict(), contract=self.contract.to_dict(), counters=counters,
                    actor=self.actor.state_dict(), actor_target=self.actor_target.state_dict(),
                    critics=[c.state_dict() for c in self.critics],
                    critic_targets=[c.state_dict() for c in self.critic_targets],
                    actor_optimizer=self.actor_optimizer.state_dict(),
                    critic_optimizers=[o.state_dict() for o in self.critic_optimizers],
                    normalizer=self.normalizer.state_dict(), replay=self.replay.state_dict(),
                    numpy_rng=np.random.get_state(), python_rng=random.getstate(), torch_rng=torch.get_rng_state(),
                    cuda_rng=torch.cuda.get_rng_state_all() if self.device.type == "cuda" else [],
                    collector_rng=self.collector_rng_state, last_episode=self.last_summary,
                    metrics=self.metrics)

    def save(self, path):
        path = Path(path)
        temporary = path.with_suffix(path.suffix + ".tmp")
        with temporary.open("wb") as stream:
            torch.save(self.state_dict(), stream)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)

    def restore(self, saved):
        self.contract.validate_saved(saved["contract"])
        if RLConfig(**saved["config"]) != self.config:
            raise ValueError("resume requires matching resolved H2 training config")
        self.actor.load_state_dict(saved["actor"])
        self.actor_target.load_state_dict(saved["actor_target"])
        for group, key in ((self.critics, "critics"), (self.critic_targets, "critic_targets"),
                           (self.critic_optimizers, "critic_optimizers")):
            for item, state in zip(group, saved[key]):
                item.load_state_dict(state)
        self.actor_optimizer.load_state_dict(saved["actor_optimizer"])
        norm = saved["normalizer"]
        self.normalizer.mean = np.asarray(norm["mean"], dtype=np.float64)
        self.normalizer.m2 = np.asarray(norm["m2"], dtype=np.float64)
        self.normalizer.count, self.normalizer.frozen = int(norm["count"]), bool(norm["frozen"])
        self.replay.load_state_dict(saved["replay"])
        for name, value in saved["counters"].items():
            setattr(self, name, int(value))
        np.random.set_state(saved["numpy_rng"])
        random.setstate(saved["python_rng"])
        torch.set_rng_state(saved["torch_rng"])
        if self.device.type == "cuda" and saved["cuda_rng"]:
            torch.cuda.set_rng_state_all(saved["cuda_rng"])
        self.collector_rng_state = saved["collector_rng"]
        self.last_summary, self.metrics = saved["last_episode"], saved["metrics"]


def learner_main(config, contract, episodes, sync, status, stop_event, run_dir, resume_path=None):
    """Sequential ingestion makes every checkpoint an exact ball/update boundary."""
    root = Path(run_dir)
    learner = None
    # The parent coordinates terminal Ctrl+C and drains every accepted episode.
    signal.signal(signal.SIGINT, signal.SIG_IGN)
    try:
        torch.set_num_threads(1)
        learner = TD3Learner(config, contract)
        if resume_path:
            learner.restore(load_checkpoint(resume_path, contract))
            sync.version.value = learner.policy_version
        learner.publish(sync)
        last_metrics_s = 0.0

        def progress():
            nonlocal last_metrics_s
            status.ingested_balls.value = learner.balls
            status.transitions.value = learner.transitions
            status.critic_updates.value = learner.critic_updates
            status.actor_updates.value = learner.actor_updates
            status.pending_updates.value = learner.pending_updates + (
                max(0, config.critic_warmup_steps - learner.warmup_updates)
                if learner.warmup_balls == config.warmup_balls else 0)
            status.warmup_done.value = int(learner.warmup_complete)
            now = time.monotonic()
            if now - last_metrics_s >= 1:
                with (root / "learner.jsonl").open("a") as log:
                    log.write(json.dumps(dict(monotonic_s=now, **status.snapshot(), **learner.metrics)) + "\n")
                last_metrics_s = now

        # Publish restored counters and warmup state before admitting the first ball.
        progress()
        status.ready.value = 1
        while True:
            # Always finish the current ball's debt before consuming another;
            # other processes keep collecting with the last complete actor.
            if learner.warmup_balls == config.warmup_balls and not learner.warmup_complete:
                status.stage.value = 2
                while not learner.warmup_complete:
                    started = time.monotonic()
                    learner.update_once(critic_only=True)
                    status.last_update_s.value = time.monotonic() - started
                    progress()
                learner.publish(sync)
            if (learner.warmup_complete and learner.online_balls == 0 and
                    not (root / "checkpoint_warmup.pt").exists()):
                status.stage.value = 4
                learner.save(root / "checkpoint_warmup.pt")
            status.stage.value = 3
            while learner.pending_updates:
                started = time.monotonic()
                previous_actor_updates = learner.actor_updates
                learner.update_once()
                status.last_update_s.value = time.monotonic() - started
                if learner.actor_updates != previous_actor_updates:
                    learner.publish(sync)
                progress()
            progress()
            if learner.online_balls and learner.online_balls % config.checkpoint_interval_balls == 0:
                path = root / f"checkpoint_online_ball_{learner.online_balls:06d}.pt"
                if not path.exists():
                    status.stage.value = 4
                    learner.save(path)
            if learner.online_balls == config.online_balls:
                break
            try:
                status.stage.value = 1
                episode = episodes.get(timeout=0.1)
            except queue.Empty:
                if stop_event.is_set() and learner.balls >= status.stop_after_balls.value:
                    break
                continue
            learner.ingest(episode)
            progress()
        status.stage.value = 4
        learner.save(root / "checkpoint_final.pt")
        status.stage.value = 5
        status.done.value = 1
    except BaseException:
        status.failed.value = 1
        status.stage.value = 6
        (root / "learner_error.txt").write_text(traceback.format_exc())
        if learner is not None:
            try:
                learner.save(root / "checkpoint_interrupted.pt")
            except Exception:
                pass
    finally:
        status.ready.value = 0