Download runtime/rl/learner.py from BAAI/PingPongRealWorldRL: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/runtime/rl/learner.py
- Command line
-
hf download hf://BAAI/PingPongRealWorldRL/runtime/rl/learner.py
-
curl -L -o learner.py https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/runtime/rl/learner.py
16.2 kB
| """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 = {} | |
| 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 | |