File size: 11,375 Bytes
dc9f917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Evaluate the controller in the real DMC Reacher simulator, against CEM.



Reacher port of ``scripts/eval_controller.py``. Differences from the PushT

original, all following the upstream LeWM eval recipe

(``stable-worldmodel/scripts/plan/config/reacher.yaml``):



* the env runs the ``qpos_match`` task: an episode counts as a success when

  every joint is within 0.05 rad of the goal configuration, which is what

  supplies a termination signal to ``world.evaluate``;

* dataset rows are pushed into the simulator with ``set_state(qpos, qvel)``

  and the goal with ``set_target_qpos(goal_qpos)`` instead of PushT's

  ``_set_state``/``_set_goal_state``;

* only ``action`` is z-scored — the qpos/qvel columns are reset plumbing,

  never seen by the policy;

* the policy is :class:`tools.history_policy.HistoryPolicy` so the solver

  gets the real strided frame history and past action blocks (commit bd459fc:

  without it 41% of the committed action is lost).



Both planners run through the same policy, the same wrappers and the same

held-out initial-state/goal pairs, so the only thing that differs is how the

plan is produced. Reports success rate and wall-clock planning time — the

central claim is approaching CEM's success with far fewer world-model

evaluations.

"""

import argparse
import json
import os
import sys
import time
from pathlib import Path

# PushT eval hardcodes egl (Linux). Windows has no egl; glfw renders into a
# hidden window and works here. Must be set before mujoco/dm_control import.
if not os.environ.get('MUJOCO_GL'):
    os.environ['MUJOCO_GL'] = 'glfw' if sys.platform == 'win32' else 'egl'

import hdf5plugin  # noqa: F401,E402  -- blosc filter for the reacher h5
import numpy as np
import stable_pretraining as spt  # noqa: E402
import stable_worldmodel as swm  # noqa: E402
import torch  # noqa: E402
from sklearn import preprocessing  # noqa: E402
from torchvision.transforms import v2 as transforms  # noqa: E402

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from lejepa_control.solver import ControllerSolver, load_controller  # noqa: E402
from lejepa_control.world_model import load_lewm  # noqa: E402
from tools.history_policy import HistoryPolicy  # noqa: E402

# qpos_match: 0.05 rad per-joint tolerance, the env's own criterion
CALLABLES = [
    {
        'method': 'set_state',
        'args': {'qpos': {'value': 'qpos'}, 'qvel': {'value': 'qvel'}},
    },
    {
        'method': 'set_target_qpos',
        'args': {'target_qpos': {'value': 'goal_qpos'}},
    },
]


def parse_args():
    p = argparse.ArgumentParser()
    p.add_argument('--controller', default='data/runs/controller_reacher/controller.pt')
    p.add_argument('--planner', default='controller', choices=['controller', 'cem', 'random'])
    p.add_argument('--refinements', type=int, default=None)
    p.add_argument('--num-eval', type=int, default=50)
    p.add_argument('--eval-budget', type=int, default=50)
    p.add_argument('--goal-offset', type=int, default=25)
    p.add_argument('--horizon', type=int, default=5)
    # execute one block then replan (spec section 9); set equal to --horizon
    # for the "execute the full plan" ablation
    p.add_argument('--receding-horizon', type=int, default=1)
    p.add_argument('--cem-samples', type=int, default=300)
    p.add_argument('--cem-steps', type=int, default=30)
    p.add_argument('--no-history', action='store_true',
                   help='drop HistoryPolicy: single repeated frame, zero past blocks')
    p.add_argument(
        '--dataset', default='data/swm_home/datasets/dmc/reacher.h5'
    )
    p.add_argument('--wm-name', default='quentinll/lewm-reacher')
    p.add_argument('--env-name', default='swm/ReacherDMControl-v0')
    p.add_argument('--out', default='data/runs/eval_reacher')
    p.add_argument('--seed', type=int, default=42)
    p.add_argument('--video', action='store_true')
    return p.parse_args()


def img_transform(size=224):
    return transforms.Compose(
        [
            transforms.ToImage(),
            transforms.ToDtype(torch.float32, scale=True),
            transforms.Normalize(**spt.data.dataset_stats.ImageNet),
            transforms.Resize(size=size),
        ]
    )


def main():
    args = parse_args()
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    world = swm.World(
        env_name=args.env_name,
        num_envs=args.num_eval,
        max_episode_steps=2 * args.eval_budget,
        image_shape=(224, 224),
        task='qpos_match',
    )

    dataset = swm.data.load_dataset(
        str(Path(args.dataset).resolve()), keys_to_cache=['action']
    )

    # only the action column is z-scored; qpos/qvel feed the reset callables
    process = {}
    data = dataset.get_col_data('action')
    data = data[~np.isnan(data).any(axis=1)]
    process['action'] = preprocessing.StandardScaler().fit(data)

    transform = {'pixels': img_transform(), 'goal': img_transform()}

    model = load_lewm(name=args.wm_name, device=device)
    model.interpolate_pos_encoding = True
    latent_dim = model.predictor.input_dim

    config = swm.PlanConfig(
        horizon=args.horizon,
        receding_horizon=args.receding_horizon,
        action_block=5,
        history_len=model.predictor.num_frames,
    )

    if args.planner == 'controller':
        controller, ckpt = load_controller(
            args.controller,
            latent_dim=latent_dim,
            device=device,
            refinements=args.refinements,
        )
        solver = ControllerSolver(model, controller, device=device)
        tag = f'controller_K{controller.refinements}'
        print(f'controller from step {ckpt["step"]}, K={controller.refinements}')
    elif args.planner == 'cem':
        cost = swm.planning.ShootingCostEvaluator(model, swm.planning.GoalMSE())
        solver = swm.planning.CEMSolver(
            cost=cost,
            num_samples=args.cem_samples,
            n_steps=args.cem_steps,
            topk=30,
            device=device,
        )
        tag = f'cem_s{args.cem_samples}_n{args.cem_steps}'
    else:
        solver = swm.policy.RandomPolicy()
        tag = 'random'

    calls = {'n': 0, 'rows': 0}
    if args.planner in ('controller', 'cem'):
        # the central claim is cost, so count predictor forwards rather than
        # asserting them: one call = one batched latent transition
        inner_predict = model.predictor.forward

        def counting_predict(*a, **kw):
            calls['n'] += 1
            first = a[0] if a else next(iter(kw.values()))
            calls['rows'] += first.shape[0]
            return inner_predict(*a, **kw)

        model.predictor.forward = counting_predict

    # terminal latent distance at each replan -> CEM regret, comparable across
    # planners because both are scored under the same frozen model. GoalMSE
    # sums over the latent dim while the controller averages, so rescale to
    # per-dim. Done by retyping the instance: __call__ is looked up on the
    # type, and the policy isinstance-checks against the Solver protocol.
    terminals = []
    if args.planner in ('controller', 'cem'):
        scale = 1.0 if args.planner == 'controller' else 1.0 / latent_dim
        base = type(solver)

        class RecordingSolver(base):
            def __call__(self, info_dict, init_action=None):
                out = base.__call__(self, info_dict, init_action)
                costs = out.get('costs')
                if costs is not None:
                    value = float(torch.as_tensor(costs).float().mean())
                    terminals.append(value * scale)
                return out

        solver.__class__ = RecordingSolver

    if args.planner == 'random':
        # RandomPolicy is a policy itself, not a solver — no WorldModelPolicy
        # wrapper, no history plumbing, no world-model calls.
        policy = solver
    else:
        policy_cls = (
            swm.policy.WorldModelPolicy if args.no_history else HistoryPolicy
        )
        policy = policy_cls(
            solver=solver,
            config=config,
            process=process,
            transform=transform,
            history_keys=('pixels',),
        )
    world.set_policy(policy)

    # held-out start/goal pairs, identical across planners for a fair compare
    names = set(dataset.column_names) | set(getattr(dataset, '_schema_names', ()))
    ep_col = 'episode_idx' if 'episode_idx' in names else 'ep_idx'
    ep_idx = dataset.get_col_data(ep_col)
    step_idx = dataset.get_col_data('step_idx')
    episodes = np.unique(ep_idx)
    lengths = {e: step_idx[ep_idx == e].max() + 1 for e in episodes}
    max_start = np.array([lengths[e] for e in ep_idx]) - args.goal_offset - 1
    valid = np.nonzero(step_idx <= max_start)[0]

    rng = np.random.default_rng(args.seed)
    picked = np.sort(valid[rng.choice(len(valid), args.num_eval, replace=False)])

    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)

    t0 = time.time()
    metrics = world.evaluate(
        dataset=dataset,
        start_steps=step_idx[picked].tolist(),
        goal_offset=args.goal_offset,
        eval_budget=args.eval_budget,
        episodes_idx=ep_idx[picked].tolist(),
        callables=CALLABLES,
        video=out_dir if args.video else None,
    )
    elapsed = time.time() - t0

    result = {
        'planner': tag,
        'env': args.env_name,
        'wm': args.wm_name,
        'receding_horizon': args.receding_horizon,
        'success_rate': float(metrics['success_rate']),
        'seconds': elapsed,
        'seconds_per_episode': elapsed / args.num_eval,
        'mean_terminal_distance': (
            float(np.mean(terminals)) if terminals else None
        ),
        # Averaging over solver calls is not comparable across execution
        # lengths: a run that replans every block makes more calls, and the
        # later ones are taken nearer the goal. The first call is taken from
        # the same held-out state by every planner, so that one is.
        'first_terminal_distance': float(terminals[0]) if terminals else None,
        'terminal_distance_trace': [float(t) for t in terminals],
        'predictor_calls': calls['n'],
        'predictor_rows_per_episode': calls['rows'] / args.num_eval,
        'num_eval': args.num_eval,
        'eval_budget': args.eval_budget,
        'goal_offset': args.goal_offset,
        'checkpoint': args.controller if args.planner == 'controller' else None,
        'seed': args.seed,
        'refinements': args.refinements,
        'history': not args.no_history,
        # per-episode outcomes: all rows share start/goal pairs, so planner
        # comparisons must be paired rather than treated as independent
        'episode_successes': [
            bool(x) for x in metrics['episode_successes'].tolist()
        ],
    }
    print(json.dumps({k: v for k, v in result.items()
                      if k != 'terminal_distance_trace'}, indent=2))

    with (out_dir / 'results.jsonl').open('a') as f:
        f.write(json.dumps(result) + '\n')


if __name__ == '__main__':
    main()