File size: 18,582 Bytes
8d56a00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
"""Pinned official Diffusion Policy components, adapted to the SO101 data contract.

The upstream encoder, ResNet-18 getter, crop randomizer, Mish U-Net and EMA are
vendored without edits. This wrapper deliberately keeps Issue 18's *current-t*
action convention, train-only min/max statistics and full-window dataset. It is
not an unmodified official task/runner or a pretrained real-robot policy.
"""
from __future__ import annotations

import copy
import hashlib
import importlib.metadata
import inspect
import json
import os
from pathlib import Path
import sys
import types

import numpy as np
import torch
from torch import Tensor, nn

HERE = Path(__file__).resolve().parent
REPO_ROOT = HERE.parents[1]
VENDOR = HERE / "vendor"
UPSTREAM_COMMIT = "5ba07ac6661db573af695b419a7947ecb704690f"
FORMAT = "so101_official_dp.visual_policy.v1"
NORMALIZER_KEYS = ("action_center", "action_span", "state_center", "state_span")
STATS_KEYS = ("action_min", "action_max", "state_min", "state_max")
DEFAULTS = {
    "history": 2, "horizon": 16, "image_size": 96,
    "crop_shape": None, "resize_shape": None, "random_crop": False, "imagenet_norm": True,
    "cameras": ["front", "gripper"], "context_dim": 2060,
    "execution_start": 0, "execute_steps": 8, "control_hz": 10,
    "down_dims": [512, 1024, 2048], "diffusion_step_embed_dim": 128,
    "kernel_size": 5, "n_groups": 8, "cond_predict_scale": True,
    "num_train_timesteps": 100, "beta_schedule": "squaredcos_cap_v2",
    "prediction_type": "epsilon", "clip_sample": True,
    "variance_type": "fixed_small", "default_sampler": "ddpm",
    "default_inference_steps": 100,
    "total_updates": 100000, "batch_size": 64, "microbatch_size": 8,
    "learning_rate": 1e-4, "adam_betas": [0.95, 0.999],
    "weight_decay": 1e-6, "warmup_steps": 500, "gradient_clip": None,
    "ema_power": 0.75, "ema_max_value": 0.9999,
    "checkpoint_every": 10000, "log_every": 100, "num_workers": 0,
    "validation_batch_size": 8, "validation_sampler": "ddim",
    "validation_inference_steps": 16, "validation_noise_seed": 7041,
    "no_real_robot": True, "test_only": False,
}


def canonical_config(config: dict) -> dict:
    cfg = copy.deepcopy(DEFAULTS)
    cfg.update(copy.deepcopy(config))
    required = {"history": 2, "horizon": 16, "image_size": 96,
                "crop_shape": None, "resize_shape": None, "random_crop": False, "imagenet_norm": True,
                "cameras": ["front", "gripper"], "context_dim": 2060,
                "execution_start": 0, "execute_steps": 8, "control_hz": 10,
                "no_real_robot": True, "num_train_timesteps": 100,
                "beta_schedule": "squaredcos_cap_v2", "prediction_type": "epsilon",
                "clip_sample": True, "variance_type": "fixed_small"}
    for key, value in required.items():
        if cfg[key] != value:
            raise ValueError(f"SO101 official-component contract requires {key}={value!r}")
    if not cfg["test_only"]:
        for key in ("down_dims", "diffusion_step_embed_dim", "kernel_size", "n_groups",
                    "cond_predict_scale"):
            if cfg[key] != DEFAULTS[key]:
                raise ValueError(f"Production architecture requires {key}={DEFAULTS[key]!r}")
    if len(cfg["down_dims"]) != 3 or any(int(x) <= 0 or int(x) % cfg["n_groups"] for x in cfg["down_dims"]):
        raise ValueError("Use three positive group-divisible U-Net channel widths")
    for key in ("batch_size", "microbatch_size", "total_updates", "checkpoint_every",
                "log_every", "validation_batch_size", "validation_inference_steps"):
        if isinstance(cfg[key], bool) or int(cfg[key]) != cfg[key] or cfg[key] <= 0:
            raise ValueError(f"{key} must be a positive integer")
    if cfg["batch_size"] % cfg["microbatch_size"]:
        raise ValueError("microbatch_size must divide effective batch_size")
    if cfg["num_workers"] != 0:
        raise ValueError("Exact-resume batch stream uses num_workers=0")
    if cfg["warmup_steps"] < 0 or cfg["learning_rate"] <= 0:
        raise ValueError("Invalid optimizer schedule")
    if cfg["gradient_clip"] is not None and cfg["gradient_clip"] <= 0:
        raise ValueError("gradient_clip must be positive or None")
    return cfg


def canonical_stats(stats: dict) -> dict:
    out = copy.deepcopy(stats)
    for key in STATS_KEYS:
        value = np.asarray(stats[key], dtype=np.float32)
        if value.shape != (6,) or not np.isfinite(value).all():
            raise ValueError(f"{key} requires six finite values")
        out[key] = value.tolist()
    for prefix in ("action", "state"):
        if np.any(np.asarray(out[prefix + "_max"]) < np.asarray(out[prefix + "_min"])):
            raise ValueError(f"Invalid {prefix} min/max")
    return out


def sha256(path: str | Path) -> str:
    h = hashlib.sha256()
    with Path(path).open("rb") as stream:
        for part in iter(lambda: stream.read(1024 * 1024), b""):
            h.update(part)
    return h.hexdigest()


def _official_imports():
    # A regular installed diffusion_policy package would otherwise take priority
    # over a namespace-only vendored snapshot. Do not silently mix the two.
    package_path = VENDOR / "diffusion_policy"
    existing = sys.modules.get("diffusion_policy")
    if existing is None:
        package = types.ModuleType("diffusion_policy")
        package.__path__ = [str(package_path)]
        package.__package__ = "diffusion_policy"
        sys.modules["diffusion_policy"] = package
    elif [Path(p).resolve() for p in getattr(existing, "__path__", [])] != [package_path.resolve()]:
        raise ImportError("Another diffusion_policy package is loaded; use an isolated process")
    from diffusion_policy.model.vision.multi_image_obs_encoder import MultiImageObsEncoder
    from diffusion_policy.model.vision.model_getter import get_resnet
    from diffusion_policy.model.diffusion.conditional_unet1d import ConditionalUnet1D
    from diffusion_policy.model.diffusion.ema_model import EMAModel
    for component in (MultiImageObsEncoder, get_resnet, ConditionalUnet1D, EMAModel):
        if not Path(inspect.getfile(component)).resolve().is_relative_to(VENDOR.resolve()):
            raise ImportError("An official component resolved outside the pinned vendor directory")
    return MultiImageObsEncoder, get_resnet, ConditionalUnet1D, EMAModel


def official_ema(model: nn.Module, cfg: dict):
    """Upstream EMA owns its input; deepcopy is REQUIRED, not done upstream."""
    *_, EMA = _official_imports()
    return EMA(copy.deepcopy(model), update_after_step=0, inv_gamma=1.0,
               power=float(cfg["ema_power"]), min_value=0.0,
               max_value=float(cfg["ema_max_value"]))


class OfficialDP(nn.Module):
    kind = "teacher"

    def __init__(self, cfg: dict, stats: dict):
        super().__init__()
        self.cfg = canonical_config(cfg)
        self.stats = canonical_stats(stats)
        for prefix in ("action", "state"):
            low = torch.tensor(self.stats[prefix + "_min"], dtype=torch.float32)
            high = torch.tensor(self.stats[prefix + "_max"], dtype=torch.float32)
            self.register_buffer(prefix + "_center", (high + low) / 2)
            self.register_buffer(prefix + "_span", (high - low).clamp_min(1e-3))
        Encoder, get_resnet, Unet, _ = _official_imports()
        shape_meta = {"obs": {
            "front": {"shape": [3, 96, 96], "type": "rgb"},
            "gripper": {"shape": [3, 96, 96], "type": "rgb"},
            "q": {"shape": [6], "type": "low_dim"}},
            "action": {"shape": [6]}}
        self.encoder = Encoder(
            shape_meta=shape_meta, rgb_model=get_resnet("resnet18", weights=None),
            resize_shape=None, crop_shape=None, random_crop=False,
            use_group_norm=True, share_rgb_model=False, imagenet_norm=True)
        self.denoiser = Unet(
            input_dim=6, local_cond_dim=None, global_cond_dim=2060,
            diffusion_step_embed_dim=self.cfg["diffusion_step_embed_dim"],
            down_dims=self.cfg["down_dims"], kernel_size=self.cfg["kernel_size"],
            n_groups=self.cfg["n_groups"], cond_predict_scale=self.cfg["cond_predict_scale"])
        from diffusers.schedulers.scheduling_ddpm import DDPMScheduler
        from diffusers.schedulers.scheduling_ddim import DDIMScheduler
        schedule = dict(num_train_timesteps=100, beta_start=0.0001, beta_end=0.02,
                        beta_schedule="squaredcos_cap_v2", clip_sample=True,
                        prediction_type="epsilon", timestep_spacing="leading",
                        steps_offset=0)
        self.noise_scheduler = DDPMScheduler(**schedule, variance_type="fixed_small")
        self.ddim_scheduler = DDIMScheduler(**schedule, set_alpha_to_one=True)

    def normalize_action(self, action: Tensor) -> Tensor:
        return 2 * (action - self.action_center) / self.action_span

    def normalize_state(self, q: Tensor) -> Tensor:
        return 2 * (q - self.state_center) / self.state_span

    def decode(self, normalized_action: Tensor) -> Tensor:
        """Linear inverse only; the evaluator logs both explicit clamp stages."""
        return normalized_action * self.action_span / 2 + self.action_center

    def encode(self, images: Tensor, q: Tensor) -> Tensor:
        if images.ndim != 6 or tuple(images.shape[1:]) != (2, 2, 3, 96, 96):
            raise ValueError("Expected [B,previous/current,front/gripper,RGB,96,96]")
        if images.dtype != torch.uint8 or tuple(q.shape) != (images.shape[0], 2, 6):
            raise ValueError("Images must be uint8 and q must be [B,2,6] environment coordinates")
        if not q.is_floating_point():
            raise ValueError("q must contain floating-point environment-normalized joint states")
        b = images.shape[0]
        rgb = images.float() / 255.0
        state = self.normalize_state(q)
        obs = {"front": rgb[:, :, 0].reshape(b * 2, 3, 96, 96),
               "gripper": rgb[:, :, 1].reshape(b * 2, 3, 96, 96),
               "q": state.reshape(b * 2, 6)}
        return self.encoder(obs).reshape(b, 2060)

    def head_predict(self, context: Tensor, noise: Tensor | None = None, *,
                     sampler: str = "ddpm", inference_steps: int = 100,
                     generator: torch.Generator | None = None) -> Tensor:
        """Return all 16 actions starting at current t, BEFORE deployment clamps.

        DDPM draws new Gaussian noise at every nonterminal reverse step. Supplying
        only initial noise does not make DDPM deterministic: pass a seeded generator
        as well. DDIM uses eta=0. Both use diffusers' official update equations.
        """
        if context.ndim != 2 or context.shape[1] != 2060:
            raise ValueError("Expected context [B,2060]")
        if sampler not in ("ddpm", "ddim") or not 1 <= int(inference_steps) <= 100:
            raise ValueError("Use ddpm/ddim with 1..100 inference steps")
        shape = (context.shape[0], 16, 6)
        if noise is None:
            sample = torch.randn(shape, dtype=context.dtype, device=context.device, generator=generator)
        else:
            if tuple(noise.shape) != shape or noise.device != context.device or noise.dtype != context.dtype:
                raise ValueError("Initial noise must match prediction shape/device/dtype")
            sample = noise.clone()
        scheduler = self.noise_scheduler if sampler == "ddpm" else self.ddim_scheduler
        scheduler.set_timesteps(int(inference_steps), device=context.device)
        finite = torch.isfinite(sample).all() & torch.isfinite(context).all()
        for t in scheduler.timesteps:
            epsilon = self.denoiser(sample, t, global_cond=context)
            finite = finite & torch.isfinite(epsilon).all()
            if sampler == "ddpm":
                sample = scheduler.step(epsilon, t, sample, generator=generator).prev_sample
            else:
                sample = scheduler.step(epsilon, t, sample, eta=0.0, generator=generator).prev_sample
            finite = finite & torch.isfinite(sample).all()
        if not bool(finite):
            raise FloatingPointError("Non-finite intermediate diffusion sample")
        return sample

    def predict(self, images: Tensor, q: Tensor, noise: Tensor | None = None, *,
                sampler: str = "ddpm", inference_steps: int = 100,
                generator: torch.Generator | None = None) -> Tensor:
        raw = self.head_predict(self.encode(images, q), noise, sampler=sampler,
                                inference_steps=inference_steps, generator=generator)
        return self.decode(raw.clamp(-1, 1)).clamp(-1, 1)

    def training_loss(self, batch: dict, *, generator: torch.Generator | None = None) -> Tensor:
        target = self.normalize_action(batch["action"])
        if target.shape[1:] != (16, 6) or batch["mask"].shape != target.shape[:2]:
            raise ValueError("Training target must be a masked [B,16,6] current-time chunk")
        noise = torch.randn(target.shape, device=target.device, dtype=target.dtype, generator=generator)
        timestep = torch.randint(0, 100, (target.shape[0],), device=target.device, generator=generator)
        noisy = self.noise_scheduler.add_noise(target, noise, timestep)
        prediction = self.denoiser(noisy, timestep, global_cond=self.encode(batch["images"], batch["q"]))
        mask = batch["mask"].unsqueeze(-1).expand_as(target)
        count = mask.sum()
        if count <= 0:
            raise ValueError("Empty action mask")
        return ((prediction - noise).square() * mask).sum() / count


def make_dataset(root: str | Path, cfg: dict, split: str):
    """Use the ORIGINAL manifest configuration; new optimizer keys are unrelated.

    No new demonstrations, boundary padding, statistics fit or test examples are
    introduced. Requested semantic fields must agree with the recorded dataset.
    """
    if str(REPO_ROOT) not in sys.path:
        sys.path.insert(0, str(REPO_ROOT))
    from experiments.so101_pbit_sim.data import SimDataset
    manifest = json.loads((Path(root) / "manifest.json").read_text(encoding="utf-8"))
    original = manifest["config"]
    shared = ("task_id", "cameras", "camera_layout", "image_size", "horizon", "history",
              "execution_start", "control_hz", "success_contract", "action", "no_real_robot",
              "train_seeds", "validation_seeds", "development_seeds")
    checked = canonical_config(cfg)
    for key in shared:
        if key in checked and checked[key] != original.get(key):
            raise ValueError(f"Original dataset semantic contract differs at {key}")
    if "test_seeds" in checked and checked["test_seeds"] != original["test_seeds"]:
        if checked.get("historical_test_seeds") != original["test_seeds"]:
            raise ValueError("Declare the complete historical test set before choosing a fresh holdout")
        used = set().union(*(set(original[k]) for k in
                             ("train_seeds", "validation_seeds", "test_seeds", "development_seeds")))
        if used.intersection(checked["test_seeds"]) or len(set(checked["test_seeds"])) != len(checked["test_seeds"]):
            raise ValueError("Fresh holdout overlaps a prior split or contains duplicate seeds")
    data = SimDataset(root, original, split)
    if data.stats.get("fit_seeds") != original["train_seeds"]:
        raise ValueError("Normalization was not fit exclusively on training episodes")
    return data


def source_record() -> dict:
    manifest_path = VENDOR / "source_manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    if manifest.get("commit") != UPSTREAM_COMMIT:
        raise ValueError("Unexpected upstream source revision")
    for row in manifest["files"]:
        path = VENDOR / row["path"]
        if sha256(path) != row["sha256"]:
            raise ValueError(f"Pinned vendor file changed: {row['path']}")
    return {"upstream_commit": UPSTREAM_COMMIT,
            "vendor_manifest_sha256": sha256(manifest_path),
            "git_commit_declared_by_launcher": os.environ.get("SOURCE_SHA"),
            "file_sha256": {p.relative_to(REPO_ROOT).as_posix(): sha256(p)
                            for p in (HERE / "model.py", HERE / "train.py") if p.is_file()},
            "versions": {name: importlib.metadata.version(name)
                         for name in ("torch", "torchvision", "diffusers", "einops")}}


def policy_payload(model: OfficialDP, *, step: int, manifest_sha: str,
                   extra: dict | None = None) -> dict:
    return {"format": FORMAT, "kind": "teacher", "config": model.cfg,
            "stats": model.stats, "state_dict": model.state_dict(), "step": int(step),
            "dataset_manifest_sha256": manifest_sha, "source": source_record(),
            "action_time_origin": "current_t", "execution_start": 0,
            "clamp_contract": "data_clamp_then_linear_decode_then_environment_clamp",
            "weights": "EMA", "extra": copy.deepcopy(extra or {})}


def atomic_torch_save(payload: dict, path: str | Path) -> dict:
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_name(path.name + ".tmp")
    torch.save(payload, temporary)
    temporary.replace(path)
    return {"path": path.name, "sha256": sha256(path), "bytes": path.stat().st_size,
            "step": payload.get("step")}


def load_policy(path: str | Path, device: str | torch.device = "cpu") -> tuple[OfficialDP, dict]:
    ck = torch.load(Path(path), map_location="cpu", weights_only=True)
    if ck.get("format") != FORMAT or ck.get("action_time_origin") != "current_t":
        raise ValueError("Not an official-component SO101 EMA inference checkpoint")
    current_source = source_record()
    for key in ("upstream_commit", "vendor_manifest_sha256"):
        if ck.get("source", {}).get(key) != current_source[key]:
            raise ValueError(f"Checkpoint official-component identity differs: {key}")
    model = OfficialDP(ck["config"], ck["stats"])
    expected = {name: getattr(model, name).clone() for name in NORMALIZER_KEYS}
    model.load_state_dict(ck["state_dict"], strict=True)
    for name, value in expected.items():
        if not torch.equal(value, getattr(model, name)):
            raise ValueError(f"Checkpoint normalizer contradicts train statistics: {name}")
    for name, value in model.state_dict().items():
        if not torch.isfinite(value).all():
            raise ValueError(f"Non-finite checkpoint tensor: {name}")
    return model.to(device).eval(), ck