ΦPhysical Hardware Intelligence

Deep dive · Larchenko, Learning to Fold, arXiv 2606.27163

Learning to Fold, end to end

How one person trained a vision-language-action policy to fold clothes with two SO-101 arms in Isaac Sim, took first place out of 62 teams, and then moved it to a real robot. Each part is explained from the problem it solves: the maths, the line of code that implements it, and what it did when we ran the released checkpoint ourselves.

Built from the technical report, Ilia Larchenko's released code (lehome_solution at ca73828), his released simulation checkpoint run on our RTX 4080 SUPER on 2026-10-17, the AWR and π*0.6 (RECAP) papers, and Chelsea Finn's CS224R slides (Spring 2026). Written for our reproduction of this work.

Every component gets the same five questions. Why: the problem it solves. How: the mechanism, with maths. Ours: what our own run showed. Fits: where it sits in the loop. Lever: what you could change to do better. Numbers marked derived are computed here from stated values; everything else is read from the report, the code or our logs, with the source named.

In one minute
  1. Start from π0.5, cloned on the organisers' scripted demonstrations: it folds sometimes.
  2. Roll it out in simulation, thousands of times, recording the policy's own estimate of success at every frame.
  3. Turn pass or fail into per-frame credit: checkpoints from the checker's own tests, withdrawn on failure, so every episode still sums to 1 or 0.
  4. Combine that credit with the success and completion estimates into one advantage per frame, falling back on plain outcomes as data ages.
  5. Train on frames drawn in proportion to \(e^{\operatorname{clip}(A,-2,2)}\) (AWR), with a token telling the policy which frames had a non-negative advantage (RECAP).
  6. At test time, keep the token on, push further with guidance, re-plan every 3 to 5 steps, and play the best of 2 or 3 sampled chunks.

0The whole machine

Under the engineering, this is an actor-critic loop with three jobs: collect episodes with the current policy, score every moment of them, and improve the policy toward the moments that scored well. What makes it unusual is how each job is done.

  • The critic is not a separate network. The policy predicts its own chance of success from the camera images, and that prediction is the value function (§4).
  • The policy is never trained with a policy gradient. Its training is supervised, and the advantage enters in two ways. AWR changes how often each frame is drawn for training (§8). RECAP feeds the advantage in as an input the policy can later be asked for (§9).
  • Collection and training never wait for each other. Machines exchange episodes and checkpoints only through the Hugging Face Hub (§12).
Rollout workers, the Hugging Face Hub, the training worker and the DAgger station, connected only through the Hub Rollout workers 3–5 Isaac Sim processes per machine one stateless policy server (GPU) pull the newest checkpoint record frames, outcome, and the policy's own success and completion predictions at collection time 6 strategies · replay · hard mining Hugging Face Hub the only shared state datasets in, checkpoints out Training worker (1 × H200) 1 recompute advantages over every rollout dataset 2 AWR: draw frames with P ∝ exp(clip(A, −2, 2)) 3 RECAP: advantage token + AdaRMS vector, gated by A 4 train ~1000 steps; upload a checkpoint every ~500 Manual DAgger station a human fixes saved failure states by teleop (report §3.4) episodes newest checkpoint new rollouts checkpoints corrected episodes
The flywheel, drawn from report §2.1. Nothing in it waits: the trainer uses whatever data has arrived, and workers roll out whatever checkpoint is newest. Colours: blue collects, teal scores, pink improves, orange is a human.

One evaluation step, read from the code

Before any learning, here is what happens each time the simulated arms move. This is the inference path we ran ourselves. Training reuses the same loop to collect episodes.

  1. The simulator sends its observation to Ilia's eval worker over HTTP (POST /infer, remote_loop.py). The observation is three 640×480 camera images plus 12 joint angles.
  2. The worker holds the current plan, a chunk of future joint targets from the model server. It hands back the next target. When the executed part of the chunk is used up, it first sends this observation to the model server for a new chunk (eval_worker.py).
  3. The target is 12 numbers, one angle per motor (each arm: five joints and a gripper). The simulator sets them as the joints' position targets (garment_bi_v2.py:281–282).
  4. The physics engine advances by one time step. Each joint's motor is modelled as a spring-damper pulling toward its target; cloth, gravity and contacts move in the same step.
  5. The cameras render, the sensors refresh, and the next observation is read. Joint angles and the top camera are fresh every step; the two wrist cameras refresh every third step.
  6. Every 30th step the competition's checker tests whether the garment is folded; on success the episode stops.
Two clocks

The report describes the simulated action space as 30 Hz, and the organisers' simulation datasets are labelled 30 fps. Inside the simulator, though, each step advances the physics by 1/90 s: the challenge's environment uses a physics step of 1/90 s and one physics step per action (garment_bi_cfg_v2.py: dt = 1/90, decimation = 1). Both are true at once, because the challenge's own recorder writes one dataset frame per simulator step and labels the file 30 fps (dataset_record.py:272).

So "30 frames = 1 s" holds on the data's clock, while the same 30 steps are 0.33 s of simulated physics. Inside the simulator nothing depends on which clock you use, because data, training and evaluation all count in steps. It matters only when sim data is mixed with real data, where the motion per step has to match; §14 shows how Ilia rescales each source's speed.

Who is who

Name hereWhat it isWhere
policy πθThe network that maps 3 images and 12 joint angles to a 30-step chunk of future joint moves§3
behaviour policy μWhatever produced the training data: the organisers' scripted demos plus every past checkpoint's rollouts. AWR and RECAP both improve on μ§8, §9
success head \(\hat P_t\)The policy's own estimate, from images only, of the chance this episode ends in a fold. It is the value function§4
completion head \(\hat C_t\)Its estimate of how far through a successful fold this moment is, from 0 to 1§4
reward \(r_t\)Dense credit from the checker's own conditions. It always sums to 1 for a fold and 0 for a failure§6
advantage \(A_t\)How much better this moment went than expected. It drives both AWR and RECAP§7
advantage tokenAn input that tells the policy "behave like your high-advantage moments". Masked during training when the advantage is negative§9
guidance scale \(s\)How far inference extrapolates from "no token" toward "with token". 7 to 9 in the final submission§11

1The task, as RL sees it

The LeHome Challenge 2026, an ICRA 2026 competition, asks two SO-101 arms to fold one garment lying flat on a table. Four garment types are scored: long-sleeved tops, short-sleeved tops, long pants and shorts. They are children's sizes, which Ilia only realised when he assembled real arms for the final round (report §9.1).

RL pieceIn this taskSource
ObservationThree RGB cameras (one overhead, one on each wrist) at 640×480, resized to 224×224 for the model, plus the 12 joint angles. The overhead camera also gives depth; the policy does not use it.report §1.1 and §4
Action12 joint targets: shoulder pan, shoulder lift, elbow, wrist flex, wrist roll and gripper, for each arm. The model plans 30 of them at a time (§3).report §1.1; organiser dataset metadata
StepOne target, then one physics step of 1/90 s (the data's clock calls it 1/30 s; see "Two clocks" in §0)env config
EpisodeAt most 600 steps. It ends early when the fold is detected.challenge eval parser
RewardBinary: 1 if the garment passes every fold condition, 0 otherwise. No partial credit in the simulation round.report §1.1
Hidden informationThe garment type is not given to the policy at test time; it has to tell from the images (§11)report §1.1

What "folded" means

The checker tracks six points on the cloth. A fold is a set of distance tests between pairs of those points: some pairs must come closer than a threshold (two sleeve ends meeting), others must stay farther apart than one (the fold must not collapse into a ball). Every test must pass at once.

TypePairs that must meetPairs that must stay apartTests
Long and short tops325
Long pants224 (Ilia's reward uses 7, §6)
Shorts224

Read from the challenge's checker, lehome/utils/success_checker_chanllege.py. Thresholds are set per garment.

In the challenge code we ran, which is Ilia's fork at 5ea947e, the test runs every 30 steps (garment_bi_v2.py:54, 331–336). So a successful episode always ends at step 29, 59, 89 and so on. Every one of our successes did: 119, 149, 239, 269, 299, 329, 359 and 419 steps.

How the leaderboard scored

Each garment type had 20 instances: 10 seen garments that came with the organisers' scripted demonstrations, and 10 unseen ones. Of the unseen, 2 per type were public and 8 per type were private and never released. Every one of the 80 garments was run 10 times. The score is the plain average of the four per-type success rates (report §8.1).

Ours

The public release holds 12 garments per type: the 10 seen and the 2 public unseen. The 8 private garments per type cannot be run by anyone outside the organisers, so no outside run can reproduce the exact leaderboard set. Our smoke test on the 8 public unseen garments folded 13 of 16 episodes (§13).

2Why not just copy, and why not PPO

The starting point is behaviour cloning: train the policy to reproduce the organisers' scripted demonstrations. That gives a policy that sometimes folds. Everything after is about raising that rate.

Why

Cloning clean demonstrations has a known failure. The demonstrations never make mistakes, so they never show how to recover from one. Once the cloned policy drifts into a state the demos never visited, it has no data for that state, and errors compound. Finn's CS224R calls this covariate shift: the states the policy visits stop matching the states it was trained on (lecture 2, slides 23–24). Cloth makes it worse, because a small difference in a grasp leaves the garment in a different shape (report §1.2).

How

A cloned policy that already folds some of the time can improve in two ways (report §2.2):

  1. Fold cleanly on the first try more often. Find the moments where the policy did better than usual and make those its normal behaviour. AWR and RECAP do exactly this.
  2. Learn to recover from a ruined state. That needs examples of recovery, which clean demos lack. DAgger supplies them by having an expert correct the policy in the states it actually visits (lecture 2, slide 25). In simulation, Ilia found teleoperating cloth too hard for this to help much. On the real robot it became one of his most useful tools (§14).
Fits

The simulation round is almost entirely route 1. The real round is route 2.

Why not policy gradients

PPO, and its group-relative variant GRPO, improve a policy by raising the log-probability of actions that did well and lowering it for actions that did badly. That needs \(\log \pi_\theta(a\mid s)\), and a flow-matching policy does not give one cheaply: it produces actions by integrating a learned velocity field from noise (§3), and the probability of the result is not tractable. The π*0.6 paper makes the same point about its own flow-matching policy.

Ilia adds a second argument (report §2.2). Valid action chunks occupy a thin set inside the space of all possible 30×12 chunks. A method that pushes probability away from bad actions moves predictions in directions the data never covered, mostly off that set. Reweighting and conditioning only move probability toward actions the policy already produces, so they stay on it. Finn frames the same property for offline RL: methods that supervise only on actions in the data implicitly constrain the policy to the data (lecture 7, slide 28).

Cost

Staying on the data's actions also means almost no exploration. These methods sharpen behaviour the policy already shows; they do not discover new moves. Ilia judged that a good trade for a competition that rewards one reliable fold, and he names the lack of recovery as the main open problem (report §10.4, and §15 here).

Lever

Ilia calls the choice of this family a subjective bet, made without a controlled comparison against PPO-style methods on this task. The π*0.6 paper does compare: on its tasks, an AWR baseline was slower and a PPO baseline stable but poor (its Fig. 11). That comparison was on PI's robots, not this task.

3The policy network

The policy is Ilia's team's BEHAVIOR-1K 2025 model, itself an extension of Physical Intelligence's π0.5, with changes for this task (report §4). Two halves:

  • A prefix that reads the scene. SigLIP-So400m/14 (frozen) turns each 224×224 camera image into tokens, and a Gemma-2B transformer reads them together with the joint state and a few special tokens.
  • An action expert, a Gemma-300M transformer. It reads the prefix's cache and produces a chunk of 30 future moves for all 12 joints.

What the prefix sees, and who sees whom

The prefix's tokens sit in groups under a staircase attention mask: each group sees itself and the groups before it, never the groups after (report §4.1).

Group, in orderTokensWhy it is placed here
1 · imagesThree cameras' image tokens, plus one learned current query tokenEvery prediction head reads the current query. Since it sits with the images, the heads must work from pixels alone (§4)
2 · state12 joint angles, each binned into 256 values with its own embedding table, plus a garment-type token (one of 4)The policy needs joint positions to act. The value heads are kept from seeing them, so they cannot shortcut through the joint state
3 · advantageOne learned advantage token, masked per sampleThe RECAP input (§9). It sees everything before it; nothing before it sees it
4 · FAST (training only)FAST tokens (a compressed action code) and a FAST queryAn auxiliary task that shapes the representation; removed from the cache before the action expert runs

Flow matching: how a chunk is made

The action expert does not output actions directly. It learns a velocity field: given a noisy chunk and a noise level, which way to move to get closer to a real chunk. At inference, you start from pure noise and follow that field in 10 steps.

Training, as written in the code (pi_modified.py:1224–1229, 1256). Take a real chunk \(a\) from the data and noise \(\epsilon\). Draw a noise level \(t\) from Beta(1.5, 1), squeezed into (0.001, 1). Mix:

\[x_t = t\,\epsilon + (1-t)\,a, \qquad u_t = \epsilon - a, \qquad \mathcal{L}_{\text{FM}} = \lVert v_\theta(x_t, t, \text{prefix}) - u_t \rVert^2 .\]
src/lehome_solution/models/pi_modified.pylines 1223–1229, 1255–1256ca73828
1223            noise = self.generate_correlated_noise(noise_rng, batch_size)
1224            time = jax.random.beta(time_rng, 1.5, 1, (batch_size,)) * 0.999 + 0.001
1225
1226            # Compute noisy actions and target velocity
1227            time_expanded = time[:, None, None]
1228            x_t = time_expanded * noise + (1 - time_expanded) * actions
1229            u_t = noise - actions
      ⋮
1255            v_t = self.action_out_proj(suffix_out[:, :self.action_horizon])
1256            action_loss_i = jnp.square(v_t - u_t)  # [B, H, D]

Here \(t=1\) is pure noise and \(t=0\) is the clean chunk; \(u_t\) is the constant velocity that carries noise to data in a straight line. Sampling integrates from \(t=1\) down to \(t=0\) with \(S=10\) Euler steps of size \(-0.1\) (pi_modified.py:1471):

\[x_{t-0.1} = x_t - 0.1\, v_\theta(x_t, t, \text{prefix}).\]

Three inherited details shape this. The starting noise is correlated across joints and time, drawn from the data's own action covariance shrunk halfway toward the identity, \(\Sigma_{\text{reg}} = 0.5\,\Sigma + 0.5\,I\). A temperature \(\tau\) scales it by \(\sqrt{\tau}\) (pi_modified.py:1615–1619). Each training example draws 5 independent (noise, \(t\)) pairs that share one prefix pass.

What a chunk contains

A chunk is 30 steps × 12 joints of changes from the current joint state. The training data's targets are converted to deltas, and the server converts the model's output back to absolute targets before anything is sent (training/config.py:416–423, all 12 joints). The delta spread grows with how far ahead a step is, roughly like a random walk, so each future position gets its own normalisation. Ilia fits a smooth curve per joint instead of using noisy per-step statistics (report §4.6):

\[\sigma_d(t) = a_d + s_d\sqrt{t + e_d}.\]
src/lehome_solution/training/config.pylines 416–423ca73828
416        if self.use_delta_joint_actions:
417            delta_action_mask = _transforms.make_bool_mask(12)
418        else:
419            delta_action_mask = _transforms.make_bool_mask(-12)
420        
421        data_transforms = data_transforms.push(
422            inputs=[_transforms.DeltaActions(delta_action_mask)],
423            outputs=[_transforms.AbsoluteActions(delta_action_mask)],

Without it, one global scale would shrink the near steps, the ones actually executed, and let the uncertain far end of the chunk dominate the loss.

The changes Ilia made for LeHome

ChangeWhat it doesWhy
Prediction heads (§4)Linear readouts for success, completion, garment type, keypoint distances, and an action-conditioned success residualThe policy becomes its own critic
Garment-type tokenOne of 4 learned embeddings next to the state. Training uses the true type; evaluation uses the model's own guess (§11)Replaces the text prompt (language input is dropped entirely)
Advantage token (§9)RECAP's "behave like your good moments" inputEnables conditioning and guidance
AdaRMS signalsThe action expert's layer norms were already modulated by the noise level \(t\). Two more vectors are added to that modulation: one per garment type, and one advantage vectorA single prefix token was too weak. Ilia saw the policy perform one garment's motions while given another's type
Exclusive self-attentionAfter attention, each token's output has its own value direction subtracted: \(z = y - \frac{y\cdot v}{\lVert v\rVert^2+\varepsilon} v\)A recent result reported lower loss at almost no cost. Not ablated here
Per-timestep normalisationThe \(\sigma_d(t)\) fit aboveKeeps the executed steps well scaled

Kept from the BEHAVIOR-1K model unchanged: no language tokens, correlated noise, soft inpainting between chunks (§11), and cross-layer mixing of the prefix cache before the action expert reads it.

Lever

Ilia says the model is larger than the task needs; he chose it to try full-scale VLA fine-tuning with RL (report §10.2). None of the architecture changes were ablated. A much smaller policy is an open and cheap experiment, and it would fit the hardware we have.

4The policy is its own value function

Standard actor-critic training keeps a second network, the critic, to score states. Ilia puts the critic inside the policy: a few cheap linear heads read the same model's representation and predict how the episode is going (report §5).

Why

Training is simpler with one model to train, serve and version. And predicting whether a fold will succeed needs much of the same understanding of the garment as choosing the next move, so the shared representation helps both. All head gradients flow into the backbone; small loss weights keep them from dominating (report §5).

How

Current-frame heads are linear readouts of the single current query token. That token sits in the image group of the attention mask (§3), so it sees pixels only: no joint state, no garment-type input, no advantage. A head that could read the joint state might learn "arm in this pose means success" instead of looking at the cloth.

HeadPredictsTrained onLoss weightUsed for
Current frame (read from the current query)
success \(\hat P_t\)P(this episode ends in a fold), sigmoid, BCERL rollouts0.05The value function: advantages (§7), live failure detection (§12)
completion \(\hat C_t\)\(t/T\), the fraction of a successful episode done, MSEsuccessful episodes only0.02A second progress signal (§7)
garment typeone of 4, softmaxall data0.02Telling the garment at test time (§11)
keypoint distancesthe checker's 21 distance ratios \(d^{(i)}=\text{dist}_i/\text{threshold}_i\)RL rollouts0.02A cheap stand-in for a world model
time to completiona 30-step TD target; legacyall data0.05Not used downstream
checkpointP(reach the mid-fold checkpoint); legacyRL rollouts0.001Not used in the final system
30 frames ahead
FAST query (training only)success, completion and keypoint distances at \(t+30\)0.01 ×3Pushes foresight into the representation
FM query: \(\Delta_{\text{success}}\)\(y - \operatorname{sg}(\hat P_t)\): the outcome minus the current success estimate, given the actions being denoised0.1Scoring candidates in best-of-N (§11)
FM query: completion, keypointsthe same quantities 30 frames ahead, conditioned on the actions0.05, 0.02The cheap world model

Report Tables 2 and 3. The flow-matching action loss has weight 1.0. \(\operatorname{sg}\) is stop-gradient: the residual head cannot drag the success head toward itself. The residual head's loss is scaled by \((1-t)\), and samples with flow time \(t > 0.5\) are dropped, since a mostly-noise chunk says little about the future.

Three corrections keep the success head usable:

  • Tail boost. A successful episode stops at once, while a near-miss lingers in its almost-folded state for many frames. Most "almost done" frames therefore come from failures, which pulls the head down just before real successes. The last 20 frames of successful episodes get 20× weight on the success loss.
  • Label smoothing toward the garment's average success rate: \(y' = 0.95\,y + 0.05\,\bar p_g\).
  • Extra weight decay of 0.001 on the head kernels, because the heads overfit badly otherwise.

Ours: the success head over 16 real episodes

Our smoke test logged the success and completion estimates each time a new chunk was planned. That happened every 5 steps for tops and every 3 for pants, and every 5 for all types in an episode's first 5 plans. Each panel is one episode.

success estimate \(\hat P\)completion estimate \(\hat C\)failure snapshot / stuck stop

Smoke test, 2026-10-17: lehome_sim on the 8 public unseen garments, 2 episodes each, seeds 42–57. x axis: step, 0 to 600. y axis: 0 to 1. Episodes that end at step 600 hit the limit; episode 6 was stopped by the stuck detector.

Ours
  • Failures fall away and successes climb. All three failures end low (0.10, 0.03, 0.34), and all thirteen successes end between 0.71 and 1.00.
  • The head can be wrong early. On short top 0 it started at 0.18 and 0.19 in both episodes, and both folded.
  • A drop is not a verdict. The failure detector saved a snapshot at frame 139 of the very first episode when the estimate dipped, and that episode still folded at step 239.
  • Completion rises on successes and plateaus on failures. It ended at 0.85 to 0.96 on successes and at 0.72 to 0.80 on the three failures.
Lever

Ilia lists more stable and better regularised predictions as open work (report §10.1). These traces are the raw material for testing that: does the estimate rank our episodes correctly, and how early?

5The RL you need

Four ideas carry everything in §6 to §10: return, value, advantage, and how to estimate advantage from a noisy value. This is the standard material, in Finn's CS224R notation where it exists, with this task's numbers attached.

Return, value, Q and advantage

The return from step \(t\) is the discounted sum of future rewards, \(G_t = \sum_{k\ge 0}\gamma^k r_{t+k}\). The value \(V^\pi(s)\) is the expected return from state \(s\) when acting with policy \(\pi\). \(Q^\pi(s,a)\) is the same expectation after first taking action \(a\). The advantage is the difference (lecture 4, slide 6):

\[A^\pi(s,a) = Q^\pi(s,a) - V^\pi(s), \qquad V^\pi(s) = \mathbb{E}_{a\sim\pi}\left[Q^\pi(s,a)\right].\]

Advantage answers the question that matters for improvement: was this action better or worse than what the policy usually does here? It averages to zero over the policy's own actions. Positive means "do more of this", negative means "do less".

Ours

Here the episode's total reward is 1 for a fold and 0 otherwise (§6), so the value of a state is simply the chance of eventually folding from it, minus reward already collected. That is why a success-probability head can serve as the value function.

Why subtract a baseline

Subtracting any quantity that depends only on the state leaves the expected policy-gradient update unchanged, because \(\mathbb{E}_{a\sim\pi}[\nabla\log\pi(a\mid s)\,b(s)] = b(s)\,\nabla\!\int\!\pi = 0\) (lecture 3, slide 17). It can still cut the variance a lot, and the value is the natural choice of baseline. That replaces the noisy return with the advantage (lecture 4, slide 10). In §7, Ilia asks how much of an imperfect value to subtract, and the answer is less than all of it.

One-step, n-step, and GAE

We never know \(Q\) or \(V\) exactly. A learned value \(\hat V\) gives the one-step TD residual:

\[\delta_t = r_t + \gamma\,\hat V(s_{t+1}) - \hat V(s_t).\]

It is low-variance but biased by whatever \(\hat V\) gets wrong. The full Monte Carlo return minus \(\hat V(s_t)\) is unbiased but noisy. Using \(n\) real rewards before bootstrapping sits between the two (lecture 4, slide 20). Generalised advantage estimation (Schulman et al. 2016) averages all the n-step estimates with geometrically decaying weights, which reduces to a short backward recursion:

\[A^{\text{GAE}}_t = \sum_{l\ge 0}(\gamma\lambda)^l\,\delta_{t+l}, \qquad A_t = \delta_t + \gamma\lambda\,A_{t+1}.\]

\(\lambda = 0\) gives the one-step estimate; \(\lambda = 1\) gives return minus value. Ilia uses \(\gamma = 0.999\) and \(\lambda = 0.99\), so \(\gamma\lambda = 0.98901\): a residual \(l\) steps ahead keeps weight \(0.98901^l\), which halves every 63 steps and falls to about 1/e after 91 derived. One step's advantage therefore mostly reflects what happens over the next few seconds of the data's clock.

Potential-based shaping

Adding \(F_t = \gamma\,\Phi(s_{t+1}) - \Phi(s_t)\) to the reward, for any function \(\Phi\) of state, cannot change which policy is optimal (Ng, Harada and Russell 1999). Summed over an episode the terms telescope, \(\sum_t \gamma^t F_t = \gamma^T\Phi(s_T) - \Phi(s_0)\), so the bonus depends only on where the episode starts and ends. In between, it moves credit earlier: an action that raises \(\Phi\) is rewarded when it happens, not only at the end. Ilia's completion signal is exactly this kind of term, with \(\Phi\) = predicted completion (§7).

Weighted imitation

The last idea is CS224R's bridge from imitation to RL (lecture 7, slides 17–21). Filtered behaviour cloning keeps only good trajectories and clones them. Advantage-weighted imitation clones everything, weighting each sample by \(\exp(A)\):

\[\theta \leftarrow \arg\max_\theta\; \mathbb{E}_{s,a\sim\mathcal D}\big[\log\pi_\theta(a\mid s)\,\exp\!\big(A(s,a)/\beta\big)\big].\]

AWR is the principled version of this line, and §8 derives it. (Finn's slides write the temperature as \(1/\alpha\) and use \(\beta\) for the behaviour policy; this page follows the AWR paper, where \(\beta\) is the temperature.)

6Reward: from pass or fail to checkpoints

Why

The competition only says pass or fail at the end. With that alone, a good grasp at step 40 gets no credit until the very end, and an easy fold looks identical to a hard one (report §6). The fix is to add intermediate credit without changing what the policy is ultimately rewarded for.

How

Ilia builds the intermediate credit from the checker's own distance tests (§1), so nothing new has to be defined. The steps below follow his code (eval/dataset_writer.py: compute_dense_reward).

1. Checkpoints are groups of the checker's tests

TypeCheckpoint 1Checkpoint 2Credit each
Long and short topstests 1, 3, 4: one pair has met, and both "stay apart" tests holdall 5 tests (the fold)0.5 + 0.5
Long pantstests 4, 5, 6 of Ilia's expanded 7all 70.5 + 0.5
Shortsall 4 tests (the fold itself)none1.0

dataset_writer.py:496–506. Tests are numbered from 0, as in the code; for tops, tests 0–2 are the three "meet" pairs and tests 3–4 the two "stay apart" pairs. The credit per checkpoint is one over the number of checkpoints. Shorts are the easiest type, so they get no intermediate checkpoint (report §6.1).

The first checkpoint can be lost: if its tests stop holding, the 0.5 is taken back, and it is paid again if they return. The second checkpoint only ever adds credit. Because the environment refreshes the checker every 30 steps (§1), reward can only change on those frames.

src/lehome_solution/eval/dataset_writer.pylines 598–622ca73828
598    for t, f in enumerate(frames):
599        cs = f.get("check_status")
600        if cs is not None:
601            first_cp_now = all(cs[idx] > 0.5 for idx in first_cp_group)
602            other_completed = sum(
603                1 for group in other_groups
604                if all(cs[idx] > 0.5 for idx in group)
605            )
606
607            # First checkpoint oscillation: emit reward delta when state changes
608            if first_cp_now and not prev_first_cp_held:
609                rewards[t] += cp_reward
610                first_cp_transitions += 1
611                if first_cp_transitions == 1:
612                    first_cp_frame = t
613            elif not first_cp_now and prev_first_cp_held:
614                rewards[t] -= cp_reward
615
616            # Other groups: monotonic reward
617            new_other = max(0, other_completed - prev_other_completed)
618            if new_other > 0:
619                rewards[t] += new_other / num_checkpoints
620
621            prev_first_cp_held = first_cp_now
622            prev_other_completed = max(prev_other_completed, other_completed)

2. Tops get the first half gradually

For tops, the first 0.5 is not paid as one jump when checkpoint 1 is reached. It is paid out as the main "meet" distance, test 1, shrinks. With \(d_t\) that distance ratio, \(m_t = \min_{\tau\le t} d_\tau\) its lowest value so far, and \(t_1\) the frame where checkpoint 1 is first reached:

\[R^{\text{cp1}}_t = 0.5\;\operatorname{clip}\!\left(\frac{d_0 - m_t}{d_0 - d_{t_1}},\,0,\,1\right), \qquad r_t \mathrel{+}= R^{\text{cp1}}_t - R^{\text{cp1}}_{t-1}.\]

Using the running minimum matters: moving the cloth back and forth earns nothing, because only a new closest approach pays. If folding the first sleeve already closes 60% of the gap, it has earned 0.3.

src/lehome_solution/eval/dataset_writer.pylines 650–661ca73828
650                # Distribute cp_reward gradually based on distance reduction
651                running_min = d_init
652                prev_cumulative = 0.0
653                for t, d in dist_samples:
654                    running_min = min(running_min, d)
655                    frac = (d_init - running_min) / (d_init - d_first_cp)
656                    frac = max(0.0, min(1.0, frac))
657                    cumulative = frac * cp_reward
658                    increment = cumulative - prev_cumulative
659                    if increment > 1e-9:
660                        rewards[t] += increment
661                    prev_cumulative = cumulative

3. On failure, everything is taken back

If the episode fails, every bit of credit it collected is withdrawn, so the episode total is exactly 1 for a fold and 0 otherwise. The withdrawal is spread evenly over the frames after \(t_p\), the last frame at which the running total reached its peak:

\[r_t \mathrel{-}= \frac{\sum_\tau r_\tau}{T - t_p - 1}, \qquad t_p < t < T.\]
src/lehome_solution/eval/dataset_writer.pylines 668–689ca73828
668    if success is False and len(rewards) > 0:
669        cum = sum(rewards)
670        if abs(cum) > 1e-9:
671            # Find the last frame where a positive reward brought cumulative to its peak.
672            # Only advance peak_frame on actual reward events (not frames where
673            # cumulative passively stays at the peak level).
674            running = 0.0
675            peak = 0.0
676            peak_frame = 0
677            for t, r in enumerate(rewards):
678                running += r
679                if r > 1e-9 and running >= peak - 1e-9:
680                    peak = max(peak, running)
681                    peak_frame = t
682            # Spread withdrawal uniformly from peak_frame+1 to end
683            n_spread = len(rewards) - peak_frame - 1
684            if n_spread > 0:
685                per_step = -cum / n_spread
686                for t in range(peak_frame + 1, len(rewards)):
687                    rewards[t] += per_step
688            else:
689                rewards[-1] -= cum  # fallback: peak at last frame
Why

Two reasons, both from the report. A checkpoint can mislead: its tests may hold while the rest of the garment is already in a hopeless state, so paying for it would reward a dead end. And taking the credit back in one spike at the last frame would put all the blame on that final step; spreading it out blames the whole stretch after the episode stopped improving.

Effect

On a failed episode that reached the checkpoint, the moves before the checkpoint keep positive credit and the moves after it get negative credit. The advantage (§7) reads this as "the approach was good, what followed was not". Over the whole episode the reward still sums to the true outcome.

What the policy is finally rewarded for

\[\sum_t r_t = \mathbb{1}[\text{success}].\]

Dense reward changes when credit arrives, never how much an episode is worth. Ilia also ignores the simulator's own shaped reward for how neat the final garment looks (report §6).

Our repeat run: long top, garment 0, seed 42, folded at step 299. Overlay, from Ilia's evaluator: S (blue), the smoothed success estimate; A (orange), the advantage from the success channel plus completion shaping, before normalisation; R (green), the reward collected so far; C (white), completion; T (cyan), time to completion. Top row: the two wrist cameras; bottom: the overhead camera. Played at 30 frames per second, the data's clock: 10 s of video is 300 steps, or 3.3 s of simulated physics.
Lever

Ilia calls reward design partly an art, and his final mix "surely far from optimal" (report §10.1). The checkpoint groups and the 0.5/0.5 split were chosen by hand and never ablated.

7Advantage, term by term

The advantage is the single number per frame that AWR and RECAP consume. Ilia's version has six ingredients. The report calls the final formula over-engineered (report §6.8), so it helps to take it apart in the order the code builds it (eval/dataset_writer.py: compute_gae_advantages).

Intuition

A frame's advantage is high when three things happen after it (report §6.8):

  • the garment makes real progress by the checker's own distances (the reward);
  • the policy's own estimate of folding goes up (the success channel);
  • its estimate of how far through the fold it is goes up (the completion channel).

The older the episode, the less its advantage trusts the model's estimates and the more it falls back on plain outcomes.

Ingredient 1 · the value is the success estimate

Because each episode's reward sums to its outcome (§6), the expected reward still to come from a state is the chance of folding minus what has already been paid (report §6.2):

\[V(s_t) = P(\text{success}\mid s_t) - R^{\text{cum}}_t, \qquad R^{\text{cum}}_t = \sum_{\tau<t} r_\tau .\]

Raw head outputs are noisy, so every consumer uses an exponential moving average, \(\bar S_t = 0.2\,\hat P_t + 0.8\,\bar S_{t-1}\). The head also has a known bias near successful endings (§4). Offline, once the outcome \(y\) is known, the last \(K=30\) smoothed values are replaced by a straight line toward \(y\):

\[\bar S_{T-K+i} = \bar S_{T-K-1} + \big(y - \bar S_{T-K-1}\big)\,\tfrac{i}{K}, \qquad i = 1,\dots,K.\]

In the code below: ema_alpha = 0.2, value_tail_k = \(K\) = 30, success_label = \(y\).

src/lehome_solution/eval/dataset_writer.pylines 710–723ca73828
710    out: list[float] = [0.0] * T
711    out[0] = float(raw[0])
712    for t in range(1, T):
713        out[t] = ema_alpha * float(raw[t]) + (1.0 - ema_alpha) * out[t - 1]
714
715    if value_tail_k > 0 and success_label is not None and T > value_tail_k + 1:
716        K = int(value_tail_k)
717        anchor_idx = T - K - 1
718        anchor = out[anchor_idx]
719        target = float(success_label)
720        for i in range(1, K + 1):
721            frac = i / K
722            out[anchor_idx + i] = anchor + (target - anchor) * frac
723    return out

Ingredient 2 · subtract only part of an imperfect value

The textbook move is to subtract the full value from the return (§5). Ilia argues that is only optimal for a perfect value estimate. He borrows CUPED, a control-variate method from A/B testing: the variance-minimising amount to subtract is \(\theta^*\hat V\), where

\[\theta^* = \rho(\hat V, G)\,\frac{\sigma(G)}{\sigma(\hat V)}, \qquad G_t = \mathbb{1}[\text{success}] - R^{\text{cum}}_t .\]

\(\theta^*\) is 1 for a perfect predictor and 0 for a useless one. During the competition he used a fixed \(\alpha_s = 0.5\) instead, believing \(\theta^*\) could not be computed. The report's own correction: it can be estimated from rollouts, and afterwards it measured roughly 0.4 to 0.8 per garment, with 0.5 near the median (report §6.2). So the dampened baseline is

\[\hat V_t = \alpha_s\big(\bar S_t - R^{\text{cum}}_t\big), \qquad \alpha_s = 0.5 .\]

Ingredient 3 · GAE over the success channel

Plug \(\hat V\) into the TD residual and run GAE with \(\gamma = 0.999\), \(\lambda = 0.99\). The terminal value is pinned to the true outcome, \(\bar S_T = y\):

\[\delta^s_t = r_t + \gamma\,\hat V_{t+1} - \hat V_t, \qquad A^s_t = \delta^s_t + \gamma\lambda\,A^s_{t+1}.\]

Expanding \(\hat V\) shows what this does to the reward (report §6.4):

\[\delta^s_t = (1 - \alpha_s\gamma)\,r_t + \alpha_s\big(\gamma\,\bar S_{t+1} - \bar S_t\big) + \alpha_s(1-\gamma)\,R^{\text{cum}}_t \;\approx\; 0.5\,r_t + 0.5\,\big(\bar S_{t+1} - \bar S_t\big).\]

With full subtraction (\(\alpha_s = 1\)), a checkpoint reward would cancel out exactly: it enters the remaining return and \(R^{\text{cum}}\) equally. With \(\alpha_s = 0.5\), half of it survives, so the frame where a checkpoint is reached keeps a positive advantage. The other half of each residual is the change in the success estimate.

Worked

A frame reaches the halfway checkpoint, so \(r_t = 0.5\), and the smoothed success estimate rises from 0.60 to 0.70. With \(\alpha_s = 0.5\):

\[\begin{aligned}\delta^s_t &= (1 - 0.4995)(0.5)\\ &\quad {}+ 0.5\,(0.999\cdot0.70 - 0.60)\\ &= 0.300.\end{aligned}\]

With full subtraction, \(\alpha_s = 1\):

\[\begin{aligned}\delta^s_t &= (0.001)(0.5) + (0.6993 - 0.60)\\ &= 0.100,\end{aligned}\]

and the checkpoint's own reward contributes only 0.0005 of that. The dampening is what keeps the checkpoint moment visibly good derived.

In the code: ema_s_arr = \(\bar S\), cum_before = \(R^{\text{cum}}\), v_hat = \(\hat V\), p_terminal = \(y\), gl = \(\gamma\lambda\).

src/lehome_solution/eval/dataset_writer.pylines 803–816ca73828
803    v_hat = [success_alpha * (ema_s_arr[t] - cum_before[t]) for t in range(T)]
804    v_hat_terminal = success_alpha * (p_terminal - cum_total)
805
806    if T == 1:
807        delta = r[0] + gamma * v_hat_terminal - v_hat[0]
808        return [delta], V
809
810    gae = [0.0] * T
811    gae[-1] = r[-1] + gamma * v_hat_terminal - v_hat[-1]
812    gl = gamma * lam
813    for t in range(T - 2, -1, -1):
814        delta = r[t] + gamma * v_hat[t + 1] - v_hat[t]
815        gae[t] = delta + gl * gae[t + 1]
816    return gae, V

Ingredient 4 · completion as potential-based shaping

The success estimate saturates above about 0.9 on easy garments and drifts as the policy changes (report §6.3). Completion does neither: what "60% folded" looks like barely depends on which policy did the folding. Ilia adds it as a potential-based term (§5) with \(\Phi_t = \alpha_c\,\bar C_t\), \(\alpha_c = 0.5\), and terminal \(\bar C_T = y\):

\[\delta^c_t = \alpha_c\big(\gamma\,\bar C_{t+1} - \bar C_t\big), \qquad \Phi^c_t = \delta^c_t + \gamma\lambda\,\Phi^c_{t+1}.\]

There is no reward in this channel. It only says whether a frame moved the fold forward.

src/lehome_solution/eval/dataset_writer.pylines 866–872ca73828
866    gae_c = [0.0] * T
867    gae_c[-1] = completion_alpha * (gamma * c_terminal - ema_c_arr[-1])
868    gl = gamma * lam
869    for t in range(T - 2, -1, -1):
870        delta_c = completion_alpha * (gamma * ema_c_arr[t + 1] - ema_c_arr[t])
871        gae_c[t] = delta_c + gl * gae_c[t + 1]
872    return gae_c

Ingredient 5 · stale episodes fall back on outcomes

Success estimates are recorded once, at collection time, on states the model had not trained on, and never recomputed: later models have trained on those episodes and would give overconfident answers. But as the policy moves on, old estimates describe an old policy. So each episode's success channel is blended with a segment advantage that uses outcomes only, weighted by how fresh the episode is (report §6.5):

\[\tilde A_t = w\,A^s_t + (1-w)\,A^{\text{seg}}_t + \Phi^c_t, \qquad w = \min(\text{sampling share}, 1).\]

An episode's sampling share starts at 1.0 and is multiplied by 0.98 each time a new batch of rollouts arrives; once it would fall below 0.1, the dataset is dropped (§12). The completion term stays at full strength for every episode, because completion does not go stale.

The segment advantage splits an episode at its first checkpoint and compares each part's reward with what is typical for that garment. Here \(p_{\text{cp}}\) is the garment's checkpoint rate, SR its success rate, and \(G(n) = (1-(\gamma\lambda)^n)/(1-\gamma\lambda)\) rescales a flat per-step value to the size a GAE sum would have:

\[A^{\text{seg}}_t = \begin{cases}\big(R_1 - \tfrac12 p_{\text{cp}}\big)\,G(n_1)/n_1 & t \le t_1\\[3pt] \big(R_2 - (\text{SR}/p_{\text{cp}} - \tfrac12)\big)\,G(n_2)/n_2 & t > t_1\end{cases}\]

An episode that never reaches the checkpoint gets \((R - \text{SR})\,G(T)/T\) on every frame. Reaching the checkpoint and then failing gives a positive advantage before the checkpoint and a negative one after, the same shape as the reward (§6).

src/lehome_solution/eval/dataset_writer.pylines 1122–1131ca73828
1122        cp_rate = gcd["n_cp"] / max(gcd["n_total"], 1)
1123        sr = garment_sr.get(garment, 0.5)
1124        cond_sr = (sr / cp_rate) if cp_rate > 0.01 else 0.5
1125        cond_sr = min(cond_sr, 1.0)
1126        garment_baselines[garment] = {
1127            "b_total": sr,                # E[total_return] = success_rate
1128            "b_seg1": cp_rate * 0.5,      # E[seg1_return] = P(reach_cp) × 0.5
1129            "b_seg2": cond_sr - 0.5,      # E[seg2_return | reached_cp]
1130            "cp_rate": cp_rate,
1131            "cond_sr": cond_sr,
src/lehome_solution/eval/dataset_writer.pylines 1190–1205ca73828
1190        if cp_frame is not None and 0 < cp_frame < n - 1:
1191            n1 = cp_frame + 1
1192            n2 = n - n1
1193            R1 = sum(drs[:n1])
1194            R2 = sum(drs[n1:])
1195            seg1 = (R1 - gb["b_seg1"]) * _geom(n1) / max(n1, 1)
1196            seg2 = (R2 - gb["b_seg2"]) * _geom(n2) / max(n2, 1)
1197            for t in range(n1):
1198                seg_values[t] = seg1
1199            for t in range(n1, n):
1200                seg_values[t] = seg2
1201        else:
1202            # No checkpoint: single segment, baseline = success_rate
1203            geom_sum = _geom(n)
1204            seg_per_step = (dense_return - gb["b_total"]) * geom_sum / max(n, 1)
1205            seg_values = [seg_per_step] * n
src/lehome_solution/eval/dataset_writer.pylines 1207–1220ca73828
1207        # Blend: w * raw_GAE_success + (1-w) * raw_segment_per_step
1208        mixed = [w * g + (1.0 - w) * s for g, s in zip(gae_raw, seg_values)]
1209
1210        # GAE_completion applied at full strength (independent of blend weight w).
1211        # Only eligible when completion predictions are available — BC/DAgger episodes
1212        # without predictions get GAE_completion=0 automatically.
1213        if completion_alpha != 0.0 and comp_preds is not None:
1214            gae_c = compute_completion_gae(
1215                comp_preds, n,
1216                completion_alpha=completion_alpha,
1217                gamma=gamma, lam=lam,
1218                success_label=success_label,
1219            )
1220            mixed = [m + c for m, c in zip(mixed, gae_c)]

Ingredient 6 · one scale, one clip, one bonus

The blended advantage is divided by a single standard deviation \(\sigma\), computed over unbiased rollouts whose sampling share is still at least 0.5, and clipped to \([-2, 2]\). The config's beta multiplies \(\sigma\) first; it is 1.0. Then the precision boost (report §6.6): per garment, the top 20% of successful episodes, ranked by how tightly the final frame passed its tightest test, get \(+0.3\) on every frame. Garments with fewer than 5 successes in the window are skipped. That nudges training toward clean folds over borderline ones.

src/lehome_solution/eval/dataset_writer.pylines 1238–1242, 1266–1269ca73828
1238    global_std = float(np.std(eligible_vals)) if len(eligible_vals) > 1 else 1.0
1239    if global_std < 1e-8:
1240        global_std = 1.0
1241    raw_std = global_std
1242    global_std *= beta
      ⋮
1266    # ── Step 4: Normalize by global STD and clip ──
1267    result: list[list[float]] = [[] for _ in episodes_info]
1268    for i in range(len(episodes_info)):
1269        result[i] = [max(clip_min, min(clip_max, a / global_std)) for a in mixed_advs[i]]
src/lehome_solution/training/precision_boost.pylines 89–99ca73828
89    margins: list[float] = []
90    for i, cmp in enumerate(cmps):
91        if success_distance is not None and i < len(success_distance) and success_distance[i] == 0:
92            continue  # stored-threshold=0 → ratio stored as 0, skip
93        if cmp == "<=":
94            margins.append(1.0 - float(cd[i]))
95        else:
96            margins.append(float(cd[i]) - 1.0)
97    if not margins:
98        return float("nan")
99    return float(min(margins))

The margin for a "meet" test is \(1 - d^{(i)}\) and for a "stay apart" test \(d^{(i)} - 1\); the score of an episode is its smallest margin.

What the training run actually used

Every value above is set in the released sim pipeline config. In the first iteration (mode: auto) only segment advantages exist, since there are no recorded estimates yet; GAE is used from the second iteration on.

configs/rl_pipeline_sim.yamllines 104–112, 115–119, 135–150, 156–159ca73828
104# BC dataset (sim teleop demonstrations)
105bc_dataset:
106  root: data/lehome_challenge_merged/four_types_merged
107  repo_id: lehome_bc
108  initial_sampling_share: 1.0
109  decay_factor: 1.0
110  min_sampling_share: 0.9
111  bc_advantage: 0.3  # fixed advantage for BC samples (uniform sampling)
112  use_for_success_labels: false  # don't force success=1 on BC — mask BC out of success/checkpoint/ttc losses
      ⋮
115# Per-dataset sampling_share decays each iteration, clamped to min_sampling_share.
116dagger_dataset:
117  decay_factor: 1.0
118  min_sampling_share: 0.1
119  dagger_advantage: 0.3
      ⋮
135# RL dataset decay
136rl_decay_factor: 0.98
137rl_min_sampling_share: 0.1
138
139# Advantage computation
140# mode: auto = segment for iter 0, gae for iter 1+. Can also force "segment" or "gae"
141advantage:
142  mode: auto
143  gae_lambda: 0.99
144  gamma: 0.999
145  weighting_clipping: [-2.0, 2.0]  # (min, max) for exp(clip(advantage, min, max)); (0,0) = uniform
146  beta: 1.0  # AWR temperature: global_std *= beta before normalizing. Higher = more uniform sampling
147  segment_only_threshold: 0.1  # Datasets with sampling_share < 0.1 auto-switch to segment-only advantages
148  success_alpha: 0.5     # Dampens the P_success transition term in GAE_success delta; 0 removes value from GAE
149  completion_alpha: 0.5  # Weight on GAE_completion (reward-free GAE over completion head); 0 disables
150  value_tail_k: 30       # Linearly interp EMA(P_success) over last K frames to true success label; 0 disables
      ⋮
156precision_boost:
157  boost: 0.3
158  top_k: 0.20
159  min_successes: 5
Code over report

Two things the code adds to the report. The organisers' demonstrations and DAgger episodes get a fixed advantage of 0.3 for the RECAP input (lines 111 and 119), and demonstrations are masked out of the success-head loss. How often those frames are drawn is set separately, by garment failure rate (§8). The report's per-frame formulas apply to RL rollouts only.

8AWR, to the core

Advantage-weighted regression (Peng, Kumar, Zhang and Levine, 2019) turns "do more of what worked" into plain supervised learning. This section derives it from the AWR paper, shows how Finn's course frames it, and then shows the one change Ilia makes and the code that implements it.

The problem AWR solves

You have data from some behaviour policy \(\mu\). Here \(\mu\) is the mix of the organisers' demos and every past checkpoint's rollouts. You want a policy \(\pi\) that does better than \(\mu\). The exact gain of switching from \(\mu\) to \(\pi\) is the advantage of \(\mu\) averaged over the states and actions \(\pi\) would visit (AWR Eq. 3):

\[J(\pi) - J(\mu) = \mathbb{E}_{s\sim d_\pi}\,\mathbb{E}_{a\sim\pi}\big[A^\mu(s,a)\big].\]

We don't have states from \(\pi\), only from \(\mu\). So AWR swaps in \(\mu\)'s states, which is accurate only while \(\pi\) stays close to \(\mu\). Closeness becomes a constraint (Eqs. 4–6):

\[\max_\pi\; \mathbb{E}_{s\sim d_\mu}\,\mathbb{E}_{a\sim\pi}\big[A^\mu(s,a)\big] \quad\text{subject to}\quad \mathbb{E}_{s\sim d_\mu}\, D_{\mathrm{KL}}\big(\pi(\cdot\mid s)\,\big\|\,\mu(\cdot\mid s)\big) \le \epsilon .\]

The closed-form answer

Turn the constraint into a penalty with weight \(\beta\), and require \(\pi\) to integrate to 1. Then, state by state, maximise over the distribution \(\pi(\cdot\mid s)\) (Eq. 7 and Appendix A):

\[\int \pi(a\mid s)\,A(s,a)\,da \;-\; \beta\int \pi(a\mid s)\log\frac{\pi(a\mid s)}{\mu(a\mid s)}\,da \;+\; \alpha_s\Big(1 - \int \pi(a\mid s)\,da\Big).\]

Setting the derivative with respect to each \(\pi(a\mid s)\) to zero gives \(A(s,a) - \beta\big(\log\pi - \log\mu + 1\big) - \alpha_s = 0\), so \(\log\pi = \log\mu + A/\beta + \text{const}\):

\[\pi^*(a\mid s) = \frac{1}{Z(s)}\,\mu(a\mid s)\,\exp\!\big(A(s,a)/\beta\big).\]

This is AWR Eq. 8, and it is the whole idea in one line. Take the data's own action distribution and tilt it: actions better than average gain probability in proportion to \(e^{A/\beta}\), worse ones lose it. The temperature \(\beta\) sets how far you may move. Large \(\beta\) stays near \(\mu\); small \(\beta\) piles almost everything onto the best actions seen.

From the ideal policy to a training loss

The ideal \(\pi^*\) cannot be written down for a neural network. So AWR finds the network closest to it, minimising \(D_{\mathrm{KL}}(\pi^*\,\|\,\pi_\theta)\). Because \(\pi^*\) is \(\mu\) times a weight, the expectation over \(\pi^*\) becomes an expectation over the data with that weight attached (Eqs. 9–10):

\[\theta \leftarrow \arg\max_\theta\; \mathbb{E}_{(s,a)\sim\mathcal{D}}\Big[\log\pi_\theta(a\mid s)\;\exp\!\big(A(s,a)/\beta\big)\Big].\]

That is behaviour cloning with a weight on every sample. The paper drops the per-state normaliser \(Z(s)\) in this practical form. Two KL directions appear: the constraint keeps \(\pi\) near \(\mu\) with \(\mathrm{KL}(\pi\|\mu)\), while the projection fits the network with \(\mathrm{KL}(\pi^*\|\pi_\theta)\).

Limits

Two ends of \(\beta\), derived here from Eq. 8 (the paper does not discuss them) derived. As \(\beta\to\infty\), every weight tends to 1 and AWR becomes plain behaviour cloning of the data. As \(\beta\to 0\), the weights concentrate on the highest-advantage samples, which is close to filtered behaviour cloning, the simple baseline CS224R recommends comparing against (lecture 7, slide 17).

As run

The AWR paper fits a separate value network by regression on TD(\(\lambda\)) returns with \(\lambda = 0.95\). It uses a fixed \(\beta = 0.05\) and clips weights at \(\omega_{\max} = 20\). Its replay buffer is first-in, first-out, holding the latest 50k samples. Each iteration it collects about 2,000 samples, takes 200 value steps and 1,000 policy steps, with minibatches of 256 (Appendix C). With replay, \(\mu\) is a mixture of past policies, and one shared value network serves as the baseline for all of them (Eqs. 40–41, 16).

CS224R

Finn presents AWR as the principled form of weighted imitation (lecture 7, slides 18–21). Its strength is that it never trains on, or queries, an action outside the data. Its weakness is that a Monte Carlo value estimate is noisy.

Ilia's version: weight the sampler, not the loss

Ilia keeps AWR's target and changes where the weight is applied (report §2.3). Instead of loading every frame and multiplying its loss by \(e^{A/\beta}\), he draws frames in proportion to their weight and leaves the loss unweighted:

\[P(\text{draw frame } i) \;\propto\; e^{\operatorname{clip}(A_i,\,-2,\,2)}, \qquad \mathcal{L} = \text{plain flow-matching loss on the drawn batch}.\]

The two are equal in expectation. If \(w_i\) is frame \(i\)'s weight and \(p_i = w_i/\sum_j w_j\), then

\[\mathbb{E}_{i\sim p}\big[\nabla\ell_i\big] = \sum_i p_i\,\nabla\ell_i = \frac{1}{\sum_j w_j}\sum_i w_i\,\nabla\ell_i ,\]

which is the weighted-loss gradient times a positive constant. The difference is cost. A frame with weight \(e^{-2}\) is almost never drawn, so its three camera images are never decoded and it never takes a batch slot. Compute goes where the weight is.

Worked

Three frames with advantages \(-1\), \(0\) and \(1.5\) get weights \(e^{-1} = 0.37\), \(1\) and \(e^{1.5} = 4.48\). So they are drawn with probabilities 6.3%, 17.1% and 76.6%. Their importance weights for the heads, \(1/(Np_i)\) with \(N = 3\), are 5.30, 1.95 and 0.44. The rarely drawn frame counts five times when it does appear, so the heads still see each frame equally on average derived.

src/lehome_solution/training/data_loader.pylines 621–623ca73828
621            elif not (src.is_bc or src.is_dagger or src.is_real) and src.use_prioritized_sampling:
622                # RL sources: sample proportional to exp(clip(advantage, clip_min, clip_max))
623                sampling_probs = self._load_priorities(src.root, all_indices, src.advantage_weighting_clipping)
src/lehome_solution/training/data_loader.pylines 924–932ca73828
924        valid_advs = all_advs[valid_indices]
925        # Clip and exponentiate
926        clip_min, clip_max = clip_range
927        if clip_min == 0.0 and clip_max == 0.0:
928            return None  # uniform sampling
929        clipped = np.clip(valid_advs, clip_min, clip_max)
930        priorities = np.exp(clipped)
931        # Normalize to probability distribution
932        priorities /= priorities.sum()
Temperature

The advantage arriving here has already been divided by its global standard deviation \(\sigma\) (§7). So, in raw units, Ilia's sampler is AWR with temperature \(\beta = \sigma\) derived. The config's beta: 1.0 multiplies \(\sigma\), so it scales this temperature.

Clipping

AWR clips the weight from above at 20 and lets it fall toward zero. Ilia clips the advantage at \(\pm 2\), which bounds the weight on both sides: from \(e^{-2} = 0.135\) to \(e^{2} = 7.39\). The best frame is drawn at most 54.6 times as often as the worst derived. The code logs this ratio for every dataset it loads.

Keeping the heads honest

Sampling by advantage tilts the data, which is what the action loss should see. The value heads must not see it that way: the success head has to learn the true chance of success, not the chance among frames AWR liked. So every drawn frame carries an importance weight that undoes the tilt, and the auxiliary losses use it while the action loss ignores it (report §2.3):

\[w_i = \frac{1}{N\,p_i\,T_{\text{ep}(i)}} .\]

\(1/(Np_i)\) cancels the sampling probability. Dividing by the episode's length \(T_{\text{ep}}\) also makes each episode count once in the head losses, however long it ran.

src/lehome_solution/training/data_loader.pylines 1553–1559, 1822–1823ca73828
1553        # IS weight: 1/(N*p). Same definition for every source (RL / BC / DAgger);
1554        # ep-len normalisation is applied below so the final is_weight field is
1555        # 1/(N*p*ep_len) — the full unbiased per-episode weight used by all aux losses.
1556        if probs is not None:
1557            chosen = self._rng.choice(len(valid), p=probs)
1558            local_mapped_idx = valid[chosen]
1559            raw_is_weight = np.float32(1.0 / (len(valid) * probs[chosen]))
      ⋮
1822        # Episode-level IS weight: 1/(N*p) / ep_len. Single field used by every aux loss.
1823        item["is_weight"] = raw_is_weight / np.float32(ep_len)

The other sources

Not every frame has an advantage. Frames from the organisers' demonstrations and from DAgger are drawn by how hard their garment still is, \(P \propto e^{3(1-\text{SR}_{\text{garment}})}\), with the same importance weights for the heads (report §2.3). Old rollout datasets fade: each dataset's share of the batch is multiplied by 0.98 whenever new rollouts arrive, and drops out below 0.1. In AWR's terms, that makes \(\mu\) a recency-weighted mixture of past policies.

scripts/run_rl_pipeline.pylines 2880–2891ca73828
2880    # Decay existing RL datasets from previous iterations
2881    if has_new_data:
2882        kept = []
2883        for ds in state["rl_datasets"]:
2884            new_share = ds["sampling_share"] * rl_decay
2885            if new_share >= rl_min:
2886                ds["sampling_share"] = round(new_share, 4)
2887                kept.append(ds)
2888            else:
2889                logger.info("Excluding RL dataset (share %.3f < %.3f): %s",
2890                            new_share, rl_min, ds["root"])
2891        state["rl_datasets"] = kept
Fits

AWR shapes which frames the policy studies. It cannot tell the policy, at test time, to act like its best frames; it only makes those frames more common in training. That second job is RECAP's (§9).

Lever

The clip range \(\pm2\), the 0.98 decay and \(\beta = 1\) were never ablated. They set how hard the policy is pushed toward its best frames and how fast old behaviour is forgotten.

9RECAP, to the core

RECAP comes from Physical Intelligence's π*0.6 paper (2025): RL with experience and corrections via advantage-conditioned policies. AWR changes how often the policy sees each moment. RECAP instead tells the policy which moments were good, as an input, and at test time asks for good.

The idea in one equation

Label every training frame with an indicator \(I\) meaning "this action was better than usual". Train one network on frames both with the label (\(\pi(a\mid o, I)\)) and without it (\(\pi(a\mid o)\)). By Bayes' rule, the labelled policy is the unlabelled one tilted by how likely the label is for each action:

\[\pi(a\mid o, I) = \pi(a\mid o)\,\frac{p(I\mid o, a)}{p(I\mid o)} .\]

If the label depends only on the advantage, \(p(I\mid o,a) = p(I\mid A(o,a))\), this is the same shape as AWR's \(\mu(a\mid s)\,e^{A/\beta}\): the data's actions, reweighted by a rising function of their advantage. The difference is where the tilt lives. AWR applies a fixed curve \(e^{A/\beta}\) to the data. RECAP lets the network learn the tilt from the labels, and the tilt can be switched on or off at inference.

Why it improves the policy

π*0.6 rests on a result from classifier-free-guidance RL (Frans et al. 2025). Sample from

\[\hat\pi(a\mid o) \;\propto\; \pi_{\text{ref}}(a\mid o)\;p\big(I\mid A^{\pi_{\text{ref}}}(o,a)\big)^{\beta} .\]

If \(p(I\mid A)\) increases with \(A\), then \(\hat\pi\) is at least as good as \(\pi_{\text{ref}}\): \(J(\hat\pi)\ge J(\pi_{\text{ref}})\) (π*0.6 §III). With \(\beta = 1\) this is just "condition on \(I\)". With \(\beta > 1\) the tilt is sharpened, and that is classifier-free guidance, written on velocities in §11.

RECAP as π*0.6 runs it

Pieceπ*0.6
Value functionA separate 670M-parameter model, distributional over 201 bins, trained by cross-entropy on Monte Carlo returns
Reward−1 per step, a fixed penalty on failure (its size is not stated), 0 at success: the value is roughly minus the time still needed
Advantage50-step: \(A_t = \sum_{t'=t}^{t+49} r_{t'} + V(o_{t+50}) - V(o_t)\)
Indicator\(I_t = \mathbb{1}(A_t > \epsilon_\ell)\), with \(\epsilon_\ell\) set per task so about 30% (pre-training) or 40% (fine-tuning) of frames are positive. Human corrections are always positive
How it entersThe text "Advantage: positive" or "negative" in the prompt, dropped 30% of the time so the unconditional model is learned too
InferenceCondition on positive (\(\beta = 1\)); optional guidance with \(\beta\) between 1.5 and 2.5
LoopEach round, retrain the value and the policy from the pre-trained checkpoint on all data so far
ResultMore than twice the throughput on diverse laundry and espresso, and about half the failures, against offline RL plus fine-tuning (Figs. 7–8)

From the π*0.6 paper (arXiv 2511.14759), §III–VI and Appendix A. The paper is internally inconsistent on two small points: whether the threshold is set as a percentile of advantages or of values, and how the indicator relates to the subtask text. Neither changes the picture above.

Ilia's version, as the code does it

Ilia keeps the idea and changes almost every detail (report §4.3).

  • No separate value network. The advantage is the one built in §7 from the policy's own heads.
  • No threshold. The advantage token is masked (neutral) whenever \(A < 0\). When \(A\ge0\), it is masked at random, less often the larger \(A\) is: the masking probability falls linearly from 0.5 at \(A = 0\) to 0.1 at \(A\ge2\).
  • Two channels. The same mask controls a token in the prefix and a vector added to every layer norm of the action expert (AdaRMS, §3).
  • Stronger guidance. At inference the token is always on, and guidance runs at 7 to 9.
src/lehome_solution/models/pi_modified.pylines 359–374ca73828
359        # Mask out (neutral) when advantage < 0; stochastic masking when >= 0.
360        is_negative = advantage < self.config.positive_advantage_threshold
361        # For non-negative: P(neutral) ramps from neutral_prob_at_zero (adv=0)
362        # down to neutral_prob_at_max (adv >= max_adv_for_neutral).
363        t = jnp.minimum(advantage / self.config.max_adv_for_neutral, 1.0)
364        t = jnp.maximum(t, 0.0)
365        p_neutral = (
366            (1.0 - t) * self.config.neutral_prob_at_zero
367            + t * self.config.neutral_prob_at_max
368        )
369        uniform = jax.random.uniform(rng, (batch_size,))
370        use_neutral = is_negative | (uniform < p_neutral)
371
372        token = jnp.where(use_neutral[:, None], 0.0, token)
373        mask = ~use_neutral
src/lehome_solution/models/pi_modified.pylines 810–820ca73828
810        if (
811            self.config.use_advantage_embedding
812            and advantage_mask is not None
813        ):
814            adv_vec = self.advantage_adarms_vec.value.astype(adarms_cond.dtype)  # [1, D]
815            adv_contrib = jnp.where(
816                advantage_mask[:, None],  # [B, 1]
817                adv_vec,                  # [1, D]
818                jnp.zeros_like(adv_vec),
819            )  # broadcasts to [B, D]
820            adarms_cond = adarms_cond + adv_contrib

Read as a label, the gate gives a token that is present with probability

\[P(\text{token on}\mid A) = \begin{cases}0 & A < 0\\[2pt] 0.5 + 0.4\,\min(A/2,\,1) & A\ge 0\end{cases}\]

derived from lines 363–370. At \(A = -0.5\) the token is never on; at \(A = 0\), half the time; at \(A = 1\), 70%; at \(A \ge 2\), 90%. A present token therefore means a non-negative advantage, and it is more often present the larger the advantage. That is the increasing \(p(I\mid A)\) the guarantee above asks for. Demonstrations and DAgger frames carry the fixed advantage 0.3 (§7), so their token is on with probability 0.56 derived.

Why

Conditioning uses every frame. A negative-advantage frame still trains the unconditional model, which is what guidance subtracts against, so nothing is thrown away. AWR alone would mostly ignore such frames. And only conditioning gives a knob at test time, the guidance scale, to ask for better than the average good behaviour.

Ours

Our runs used the token and guidance exactly as shipped. The worker logged cfg=7 for tops and shorts and cfg=9 for long pants on every episode, matching the released config.

Lever

The ramp from 0.5 to 0.1 and its saturation at \(A = 2\) were set by hand. π*0.6 binarises at a percentile instead. Which labelling works better for this task is untested.

10AWR and RECAP together

Ilia uses both at once, because they act at different points. AWR acts on the training data: good frames are drawn more often. RECAP acts on the question asked at test time: produce what your positive-advantage frames did. Guidance then pushes further in that direction. The report's Figure 3 makes the point with a toy; the explorer below is that toy, computed exactly.

Toy

One action dimension. The data mixes a large "bad" mode (70%) and a smaller "good" mode (30%). Each action's advantage is a smooth function, negative around the bad mode and positive around the good one. Advantage estimates are noisy, and the noise slider sets how noisy. Four distributions are drawn:

  • data: what the rollouts contain;
  • AWR: the data reweighted by \(e^{\operatorname{clip}(A,-2,2)}\), what training sees;
  • + RECAP: that, times the probability the token is on (Ilia's gate, averaged over the estimate noise), which is what conditioning on the token asks for;
  • + guidance: the gate raised to the guidance scale, the sharpened version.
Read

The readout gives the share of each distribution on the good mode. With noise, some bad actions get a positive estimate and the token, so RECAP alone leaves some "spillover", as the report's figure shows. Doing both, and adding guidance, leaves almost nothing on the bad mode.

The toy multiplies densities exactly. A flow-matching policy only approximates this: guidance extrapolates velocities (§11), which matches sampling from the sharpened density only in the limit of a perfect score model. At high scales, real samples can also drift off the action manifold.

AWRRECAP
Acts onwhich frames are trained onwhat the policy is asked to produce
Uses the advantage asa sampling weight, \(e^{\operatorname{clip}(A,-2,2)}\)an input: a gated token and an AdaRMS vector
Frames it mostly ignoreslow-advantage ones, almost never drawnnone: negative frames train the unconditional model
Knob at test timenonethe guidance scale
Fails whenthe advantage is wrong, so the wrong frames are favouredthe advantage is noisy, so labels leak onto bad actions
Codedata_loader.py:926–932pi_modified.py:359–374, 810–820

11Inference: guidance, chunks, best-of-N

The same weights fold more or less often depending on how they are run (report §7). Nothing here changes the weights. The model server is stateless: each request is one observation in, one chunk out. Anything that looks like memory, such as the chunk being played, the anchor for the next chunk or the latched garment type, lives in the client (the eval worker in sim, the robot program on hardware).

Making one chunk: 10 Euler steps, each with guidance

Every chunk is 30 steps × 12 joints, made by integrating the learned velocity from noise (\(t = 1\)) to clean actions (\(t = 0\)) in \(S = 10\) steps (§3). At each step the action expert runs twice over the same prefix cache: once with the advantage signal on (in both the token and the AdaRMS vector), once with it off. The two velocities are combined:

\[\hat v = v_{\text{uncond}} + s\,\big(v_{\text{cond}} - v_{\text{uncond}}\big).\]

With \(s = 1\) this is plain conditioning; \(s > 1\) extrapolates past it, in the direction that separates "good" from "any" behaviour. In score terms, \(v_{\text{cond}} - v_{\text{uncond}}\) plays the role of \(\nabla_a \log p(I\mid a)\), and \(s\) is the exponent \(\beta\) of §9. The expensive vision-language pass runs once per chunk; guidance only doubles the cheap action expert.

src/lehome_solution/models/pi_modified.pylines 1471, 1616–1619, 1735–1740, 1745ca73828
1471        dt = -1.0 / num_steps
      ⋮
1616        if noise_temp.ndim == 0:
1617            noise = noise * jnp.sqrt(noise_temp)
1618        else:
1619            noise = noise * jnp.sqrt(noise_temp[:, None, None])
      ⋮
1735                cs = cfg_scale
1736                if cs.ndim == 1:
1737                    cs = cs[:, None, None]
1738                v_t = v_uncond + cs * (v_cond - v_uncond)
1739            else:
1740                v_t = v_cond
      ⋮
1745            x_t_new = x_t + dt * v_t

Line 1471 is the step size, 1617 the noise temperature \(\sqrt{\tau}\), 1738 guidance, 1745 the Euler update.

Ours

The final scales are high: 7 for tops and shorts, 9 for long pants. π*0.6 uses 1.5 to 2.5. Ilia's bandit started with a range of 0 to 2, kept choosing the top value, and he shifted the range up until it settled around 5 to 11 (report §7.7).

Playing the chunk

KnobMeaningtops (long, short)pants (long, short)
executed \(n_e\)targets played before planning again5, 53, 3
playback \(\lfloor k n_e\rfloor\)steps those targets are stretched over (\(k = 1\): no stretch)5, 53, 3
anchor \(n_a\)targets after the executed slice kept to seed the next chunk6, 33, 3
inpainting onset \(t_{\text{ip}}\)the anchor pulls only while the flow time is above this0.4, 0.40.5, 0.5
guidance \(s\)§above7, 79, 7
noise temperature \(\tau\)starting noise scaled by \(\sqrt{\tau}\); below 1 stays nearer the mode0.9, 0.70.7, 0.7
candidates \(N\)chunks drawn per plan for best-of-N2, 33, 3

Report Table 4 = configs/rl_pipeline_sim.yaml, per garment type. Our runs loaded exactly these values; the worker logged them at the start of every episode.

Soft inpainting keeps consecutive chunks consistent. Without it, each new plan could jump to a different way of doing the fold. The anchor's targets gently pull the new chunk's matching steps toward them during the early, noisy part of denoising. The pull spreads to the rest of the chunk through the action covariance, then switches off for the final steps so the chunk can still correct itself (report §7.2). The bandit preferred light anchoring: some beats none, strong anchoring hurts.

Short execution windows won. The bandit converged to 3 to 5 executed steps out of 30 predicted, which suggests the model's near-term predictions are far more reliable than its distant ones (report §7.7).

Best-of-N, and how often it gives up

With \(N > 1\), the server denoises \(N\) chunks from the same prefix with different seed noise. It scores each by the action-conditioned success residual \(\Delta_{\text{success}}\) (§4), averaged over the conditional and unconditional passes, and plays the best. If every candidate is predicted to make things worse (all scores negative), it draws \(2N\) more and picks from the pooled \(3N\) (report §7.4).

src/lehome_solution/shared/eval_wrapper.pylines 214–220ca73828
214            def _score(g: dict) -> float:
215                sc = g.get("wm_flow_success_cond")
216                su = g.get("wm_flow_success_uncond")
217                if sc is None or su is None:
218                    return float("-inf")
219                # Pure Δsuccess average (cond/uncond). Already a delta over V̂.
220                return 0.5 * (float(sc) + float(su))
src/lehome_solution/shared/eval_wrapper.pylines 236–242, 252ca73828
236            retry_needed: list[int] = []
237            for i, grp in enumerate(groups):
238                if n_list[i] <= 1:
239                    continue
240                fin = [s for s in (_score(g) for g in grp) if np.isfinite(s)]
241                if fin and max(fin) < 0.0:
242                    retry_needed.append(i)
      ⋮
252                R_per_group = [2 * n_list[i] for i in retry_needed]
src/lehome_solution/shared/eval_wrapper.pylines 278–285ca73828
278            for grp in groups:
279                arr = np.asarray([_score(g) for g in grp], dtype=np.float64)
280                finite = np.isfinite(arr)
281                best = int(arr.argmax()) if finite.any() else 0
282                chosen = dict(grp[best])  # shallow-copy so we don't mutate the original
283                if finite.any():
284                    fin = arr[finite]
285                    chosen["best_of_n_score_chosen"] = float(arr[best])
Ours

In our 16-episode smoke test, 680 of 1,241 plans (55%) triggered the redraw. By type: long pants 54%, shorts 67%, long tops 55%, short tops 48%. Even after redrawing, the chosen chunk's predicted gain was positive only 34% to 53% of the time, with a median near zero.

That fits two statements in the report. The residual head's predictions barely correlated with real outcomes in Ilia's experiments. And best-of-N seems to help mainly by avoiding the occasional bad chunk at rare decision points, not by finding a best one (report §7.4). It also means the average plan costs about twice the action-expert passes the table suggests derived.

Which garment is this?

The policy takes the garment type as an input, but at test time nobody says what it is. The client works it out (report §7.6):

  1. A throwaway first call reads the model's own garment-type prediction from the first frame.
  2. The first few plans refine it by majority vote, and these plans also run with a short window of 5 executed steps for every type, so the policy reacts quickly while the type settles.
  3. The type is then frozen and fed back as the input for the rest of the episode.
scripts/eval_worker.pylines 1151–1166ca73828
1151                    if gt_memory_current is None:
1152                        warmup_msg = _encode_obs_for_policy(
1153                            decoded,
1154                            self.session_id,
1155                            garment_type_input_id=0,
1156                        )
1157                        await ws_policy.send(json.dumps(warmup_msg))
1158                        warmup_resp = json.loads(await ws_policy.recv())
1159                        warmup_gt = warmup_resp.get("garment_type_pred")
1160                        gt_memory_current = warmup_gt if warmup_gt is not None else 0
1161                        logger.info(
1162                            f"{self.label} ep{current_ep_idx}: "
1163                            f"garment type warmup prediction: {gt_memory_current}"
1164                        )
1165
1166                    if action_chunk is None or chunk_idx >= execute_in_n_steps:
scripts/eval_worker.pylines 1202–1206ca73828
1202                        _FAST_WARMUP_CALLS = 5
1203                        if policy_call_count < _FAST_WARMUP_CALLS:
1204                            effective_inference_config["actions_to_execute"] = 5
1205                            effective_inference_config["execute_in_n_steps"] = 5
1206                        policy_call_count += 1

In our runs the first guess was right on all 16 episodes and never changed: 0 for long tops, 1 for short tops, 2 for long pants, 3 for shorts.

Tuning seven knobs with a bandit

Every knob in the table was tuned per garment type during rollout collection, not by grid search (report §7.7). Each candidate value of each knob is an arm with a Beta posterior, and the knobs are treated independently. For an exploration episode, each knob draws one sample per arm and takes the largest (Thompson sampling). After the episode, its outcome minus the garment type's average success rate updates the chosen arms. Posteriors decay toward uniform each iteration (factor 0.90 in the config) so the bandit follows the moving policy; replay and curriculum episodes are excluded because they are deliberately easier or harder. Once settled, the best values were frozen for the submission.

src/lehome_solution/eval/inference_optimization.pylines 114–120ca73828
114    values = []
115    samples = []
116    for v_str, ab in param_prior.items():
117        values.append(float(v_str))
118        samples.append(rng.beta(ab["alpha"], ab["beta"]))
119    best_idx = int(np.argmax(samples))
120    return values[best_idx]
src/lehome_solution/eval/inference_optimization.pylines 197–204ca73828
197) -> dict:
198    """Update prior with episode results using Thompson Sampling.
199
200    Uses baseline-subtracted reward ``r = ep_success − SR(garment_type)`` split
201    into non-negative deltas: ``α += max(r, 0)``, ``β += max(−r, 0)``. The
202    per-type baseline matches the optimization objective (total SR across
203    types) and gives a lower-variance baseline than per-garment SR.

12The flywheel: collecting data

Simulation speed limits everything: the report puts one episode at about 30 s in Isaac Sim, without saying on what hardware or for how many steps (report §3). Ours took 1.3 to 5.8 minutes each on our RTX 4080 SUPER (§13). So the collection side exists to get as many useful episodes as possible out of each sim-hour.

The plumbing

  • Parallel sims, one server. Each machine runs 3–5 simulator processes, because the competition environment holds one scene per process. Each is a thin client of one shared, stateless policy server; chunks are cached on the client.
  • Stop early. An episode stops on success, and a stuck detector ends hopeless ones. A watchdog restarts simulators that hang.
  • Record everything at collection time. Every episode is saved with the success and completion estimates the policy made while acting. Those are what §7 consumes, never re-predicted later. Each episode also gets a debug video with live reward, value and advantage traces.

The stuck detector, as the code defines it: over the last 120 frames (4 s on the data's clock, 1.3 s of physics), the joint states, the actions and the success estimate must all have a mean standard deviation below 0.01. The episode is then cut back to keep only the first 30 frames of the stuck stretch.

src/lehome_solution/constants.pylines 32–36ca73828
32STUCK_WINDOW_SECONDS = 4  # Rolling window for stuck detection (seconds)
33STUCK_WINDOW = STUCK_WINDOW_SECONDS * FPS  # 120 frames
34STUCK_THRESHOLD = 0.01  # Std threshold below which state/action is considered stuck
35STUCK_KEEP_SECONDS = 1  # Keep 1 second into the stuck window
36STUCK_KEEP = STUCK_KEEP_SECONDS * FPS  # 30 frames
scripts/eval_worker.pylines 1537–1550ca73828
1537        window = trajectory[-self.STUCK_WINDOW:]
1538        states = np.array([f["observation.state"] for f in window], dtype=np.float32)
1539        actions = np.array([f["action"] for f in window], dtype=np.float32)
1540
1541        s_std = np.mean(np.std(states, axis=0))
1542        a_std = np.mean(np.std(actions, axis=0))
1543
1544        if s_std < self.STUCK_THRESHOLD and a_std < self.STUCK_THRESHOLD:
1545            # Also check value std if available (consistent per-episode)
1546            if window[0].get("success_pred") is not None:
1547                v_std = np.std([f["success_pred"] for f in window])
1548                if v_std >= self.STUCK_THRESHOLD:
1549                    return False  # Value changing → not stuck
1550            return True

Six ways to choose what to collect

StrategyWhat it runsCounts toward success rates?
randomgarments drawn uniformly from the seen poolyes
fullevery garment, \(N\) episodes eachyes
curriculumgarments near a chosen target success rateyes
success replayrestore an early state of a successful episode and rerun it, with heavier augmentationno
semi-success replayrestore a near-success state, replay open-loop up to it, then hand over to the policyno
hard miningrestore a saved failure state and try againno

Report Table 1. Replays and hard mining are deliberately easier or harder than a fresh episode, so they still train the policy but are kept out of the success statistics, the advantage baselines and the bandit.

Saving states worth replaying

Replays need saved physics states: every cloth particle's position and velocity, plus the joints.

  • Success states are saved at step 5 of episodes that end in a fold, with probability \(1 - \text{SR}\), so rare successes are always kept.
  • Failure states are saved at the moment the policy visibly ruins a promising episode. The rule: the smoothed success estimate drops more than 0.12 below its running maximum, after that maximum has passed 0.25, ignoring the first 3 plans.
  • Semi-success states come from failed episodes that reached the first checkpoint or held a high estimate. They can also go to the human DAgger queue.
scripts/eval_worker.pylines 244–247, 1301–1311ca73828
244    EMA_ALPHA = _EMA_ALPHA    # EMA smoothing on chunk-level values
245    EMA_SKIP_CHUNKS = 3       # skip first ~3 chunks (~2 seconds) to avoid initial spike
246    EMA_DROP_THRESH = 0.12    # EMA must drop this much from running max
247    EMA_PEAK_THRESH = 0.25    # running max must exceed this before drop counts
      ⋮
1301                        if chunk_value is not None and ep_restore_data is None:
1302                            ema_chunk_count += 1
1303                            ema_value = self.EMA_ALPHA * chunk_value + (1 - self.EMA_ALPHA) * ema_value
1304                            if ema_chunk_count > self.EMA_SKIP_CHUNKS:
1305                                ema_max = max(ema_max, ema_value)
1306                                drop = ema_max - ema_value
1307                                if (drop > self.EMA_DROP_THRESH
1308                                        and ema_max > self.EMA_PEAK_THRESH
1309                                        and failure_frame is None):
1310                                    failure_frame = frame_idx
1311                                    failure_state_requested = True
Ours

The same code paths run during evaluation. In our smoke test the failure rule fired five times: at frames 139, 91, 97, 94 and 215 of five different episodes. Three of those episodes failed; two went on to fold. The stuck detector ended one episode, at frame 178 (the panel marked in §4).

Augmenting the simulator

Augmentation is applied at two scopes (report §3.3). Per episode: garment texture swaps, colour remaps, small changes to the garment's pose, scale and roughness, camera position and focal length, robot base position, table texture and light direction. Per step: garment tint, arm colour and light colour. Normal rollouts used a light regime, enough variety without hurting the live policy: texture swap and colour remap with probability 0.4, ±2 cm and ±2° of garment pose, ±5% scale. Success replays used much stronger settings, since the policy's performance there does not matter.

The training loop

  1. Download whatever new rollouts have arrived.
  2. Recompute advantages over every rollout dataset (§7).
  3. Train about 1,000 steps, drawing frames from all sources at once (§8).
  4. Upload a checkpoint every 500 steps.

Datasets are never merged; the mix is a setting. Each new rollout dataset enters at share 1.0 and fades by 0.98 per update. The demonstrations keep a fixed share (no decay in the config), and a few old successful datasets were kept at a higher weight for the hardest garments (report §2.4).

Rollbacks. Three times in the main round, and once in the second, Ilia went back to a checkpoint from a few days earlier and retrained it on everything collected since. The rolled-back model sees a large, varied, partly off-policy batch at once, and this reliably got training out of local optima (report §2.5). π*0.6 does the same thing systematically, restarting every round from the pre-trained checkpoint.

Training setupValue
Hardwareone H200 for training; rollouts mostly on an RTX PRO 6000
Steps, batchabout 300k steps at batch 192, plus side experiments
Startπ0.5 base weights, then a 20k-step behaviour-cloning warm-up before the first rollouts
OptimiserAdamW, cosine schedule from 10−4 to 10−5 over 100k steps, bfloat16, SigLIP frozen, 5 flow samples per example
Data, at the endabout 12,500 rollout episodes (4.3M frames) from about 140 collection sessions; several times more were generated over the run and aged out

Report §2.6 and §8.2. Ilia does not consider this sample-efficient. He suspects the missing ingredient is recovery behaviour: the scripted demonstrations had little exploration, so the policy struggled to find better behaviour quickly.

13Results, and what we measured

The online round

RankTeamLong topShort topLong pantsShortsOverall
1ilya (this work)74.5%70.0%80.5%93.5%79.63%
2Shubham @ Vorwerk73.0%62.5%71.5%87.0%73.50%
3Dum-E76.5%62.0%75.5%79.5%73.38%
4SCUT-Unlimited65.5%66.0%70.0%91.0%73.13%
5GraspYesAI73.5%61.0%69.0%79.0%70.63%

Report Table 5, top five of 62 teams. Each score is over 20 garments per type, 10 episodes each (§1).

The win was broad: first outright on short tops, long pants and shorts, third on long tops (behind Dum-E's 76.5% and a 77.0% from a team outside this top five), and 6.1 points clear overall. Shorts were the easiest type and short tops the hardest, for Ilia and for most of the leaderboard. Short tops need small sleeves folded to tight tolerances, and they were still improving when he submitted (report §8.1). The released lehome_sim checkpoint is the one behind this result. Ilia also notes that the 8 private unseen garments per type scored only slightly below the trained ones (report §10.1).

The failures that remained fall into three groups (report §8.2):

  • Precision: the fold looks right, but one distance ends just on the wrong side of its threshold.
  • Simulator physics: a grasp fails or the garment slips out of the gripper.
  • No recovery: after a mistake, the policy rarely works its way back.

What we measured on our RTX 4080 SUPER

Smoke test

16 episodes, 2 on each of the 8 public unseen garments, seeds 42–57: 13 folded. Long pants 3/4, shorts 3/4, long tops 4/4, short tops 3/4. A 90% interval for 13 of 16 runs from 61% to 92% derived, so this says nothing yet about matching 79.63%. The run took 46.8 min, with a GPU peak of 12,127 MiB of the card's 16,376 MiB. After the 3.5-minute start-up, single episodes took between 1.3 and 5.8 minutes of real time, garment changes included.

Repeat

We reran the same 16 episodes, with the same seeds, to record the videos used on this page. The comparison is in the table below. Our settings differed only in what each run saved, yet 10 of the 16 episodes changed: 3 failures became folds, and 7 folds finished at a different step. Fixed seeds did not make these runs repeat, so any success rate we report needs enough episodes and an interval.

GarmentSeedSmoke testRepeatChange
long pants 050fold 239fold 239same
long pants 051fold 269fold 269same
long pants 152fail 600fold 419fail → fold
long pants 153fold 239fold 239same
shorts 054fold 149fold 149same
shorts 055fail 297fold 149fail → fold
shorts 156fold 149fold 119length changed
shorts 157fold 119fold 149length changed
long top 042fold 299fold 299same
long top 043fold 299fold 269length changed
long top 144fold 299fold 269length changed
long top 145fold 329fold 359length changed
short top 046fold 359fold 329length changed
short top 047fold 419fold 329length changed
short top 148fold 269fold 269same
short top 149fail 600fold 299fail → fold
Total13/16 folded16/16 folded6 identical, 3 fail → fold

Both runs: lehome_sim at 79dcf9e, our clone at 4af6fcc, the same 16 seeds, one simulator, the same machine. The smoke test ran from 13:22 to 14:09, the repeat from 14:41 to 15:24. The smoke test ran with --metrics_only; the repeat also saved videos and per-frame data.

Coverage

The public release holds 12 of each type's 20 leaderboard garments (§1). A full reproduction (step S3 of our plan) can run all 48 of them; the 8 private garments per type cannot be run by anyone outside the organisers.

Long pants, garment 1, seed 52: it failed at step 600 in the smoke test and folded at step 419 here, with the same seed.
Short top, garment 1, seed 49: it failed at step 600 in the smoke test and folded at step 299 here.

14The real-robot round

The final round, at ICRA 2026, ran the top 8 teams' policies on a physical robot. Ilia had about three months for simulation and a little over one week for this (report §9). It was second place, with 865 of 1,080 points, behind 895.

Why the sim policy did not transfer

Zero-shot transfer failed, and Ilia's evidence points to overfitting on rendering details. His model resizes each 640×480 image to 224×224. Resizing in two steps instead, to 320×240 first and then to 224×224, is a change people can barely see, yet it cut the simulation success rate significantly. The model's heads could tell the two resizings apart perfectly (report §9.1). The real data also folded some garments differently from the simulation, and Ilia never had the evaluation robot itself: the transfer was sim, to his robot, to theirs.

The recipe

  1. Start from a strong but not the latest sim checkpoint, the latest being the most specialised to the simulator.
  2. Strip the sim-only machinery. Still trained: the action loss, the garment-type head and the completion head. Loaded but frozen: success, checkpoint and time to completion. Removed: the keypoint head, both future-prediction heads, the advantage token and AdaRMS vector, and therefore guidance and best-of-N. Real inference is the plain path: one denoised chunk, soft inpainting and the garment-type bootstrap.
  3. Fine-tune on three buckets of data (table below).
  4. Align the rigs: a camera-overlay tool drives the robot to a dataset frame's joint state and overlays the live cameras on that frame.
  5. Augment heavily: per-camera colour, gain and gamma, blur, noise, crop, rotation and zoom, cutout, camera dropout, and noise or dropout on the joint state so the policy trusts images over a miscalibrated arm.
  6. Match motion speed across sources (below).
  7. Collect DAgger data and weight it by closeness to an intervention (below).
BucketEpisodesFramesBatch shareRole
Organiser demonstrations500187,13560%closest to the evaluation rig
Ilia's teleop + DAgger, at home321 + 471162,363 + 288,19830%the only source of recovery behaviour; down-weighted because the rig differs
Sim success replays1,723625,11110%regularisation and variety

Report Table 6.

Speed factors: matching motion per step

The model predicts joint changes over a fixed number of steps. A slow source teaches small changes per step and a fast one large changes, so mixing them naively teaches an inconsistent idea of how far to move in one step. Each source's time axis is resampled before training: organiser data ×1.0, home teleop ×1.5, DAgger corrections ×2.0, sim rollouts ×0.65 (report §9.9). A factor above 1 compresses a slow source; below 1 stretches a fast one. The RL-trained sim policy was the fastest of all.

DAgger weighting without a value function

On the real robot there is no checker, so no reward, value or advantage. Ilia used the interventions themselves as the signal (report §9.10):

  • frames where the human was driving get the highest sampling weight;
  • autonomous frames far from any intervention get a low weight;
  • autonomous frames in the 5 s before a takeover ramp down to zero, so the moves that caused the failure are not reinforced.

He calls this a deliberately crude stand-in for a real advantage function. A learned value on the real robot would bring back conditioning, guidance and best-of-N.

The units bug

Between two LeRobot 0.4.x releases, the SO-101's default joint representation changed from a −100…100 range-of-motion scale to degrees. Across a roughly 200° range the two look almost the same, yet the mismatch skews every joint by about 10%. Ilia found it two days before the deadline and fixed it by post-processing (report §9.11).

A fold on Larchenko’s real rig, at 1.5× speed. The clip ships with his released lehome_real model (Apache-2.0).
His DAgger rig, from his video, at 2× speed. While the policy folds, the yellow leader arms follow the red ones, so he can take over mid-motion without a jump.
Ours

This is the round we are reproducing on our own rig: the released lehome_real checkpoint, served from our RTX 4080 SUPER. On recorded demonstrations it passed our replay test, with error 0.385× that of holding still. It answers in 0.28 s per request. The rig itself is waiting on a replacement motor. The plan and every result are in the learning-to-fold repo.

15What is still open

Ilia names two problems himself (report §10.4–10.5).

Recovery

Every tool in the simulation round pushes toward behaviour already in the data. Conditioning and guidance amplify the average good behaviour, best-of-N picks the least bad of similar samples, and inpainting keeps the policy in the mode it chose. None of them invents a recovery move. His attempts at exploration mostly pushed chunks off the action manifold. The only reliable source of recovery was a human, and that only worked on the real robot.

One pipeline

Round one had the full RL machinery but no recovery data. Round two had recovery data but ran as plain behaviour cloning, because there was no real-side reward or value. A single pipeline would combine them: a real-side value function driving conditioning and best-of-N, prioritised sampling over real rollouts, and DAgger weighted by that same value. Ilia guesses 90% or better is reachable on this task.

Levers for us

LeverWhy it could payWhat it needs
A real-side value functionBrings §7–§11 to the real robotA learned reward and critic on real data, the loop our planned EXPO-FT experiment would build
DAgger weighted by valueReplaces the hand-set 5 s rampThe same value function
More clean, easy completionsIlia over-collected hard starting states (report §9.10); the final round's states were mostly easyRig time
A smaller policyIlia thinks the task needs a much smaller model (report §10.2)A training run on Northeastern's Explorer cluster
Check the success headOur traces (§4) can test how early, and how well, it ranks outcomesOur logs, already collected
Measure, then change one thingNothing in the report was ablated. Every knob above is a one-variable experimentA reproduced baseline first: step S3 of our plan (§13)

16Notation decoder

SymbolMeaningValue hereCode name
Time
\(t\) (steps)simulator step; one action, one physics step1/90 s of physics; 1/30 s on the data's clockframe_idx
\(T\)episode length≤ 600max_steps
\(H\)chunk length30ACTION_HORIZON
\(t\) (flow)flow-matching noise level; 1 = noise, 0 = clean10 Euler stepstime, dt
Value and advantage
\(\hat P_t\), \(\bar S_t\)success estimate; its moving averageEMA weight 0.2values, ema_s_arr
\(\hat C_t\), \(\bar C_t\)completion estimate; its moving averageEMA weight 0.2completion_preds, ema_c_arr
\(r_t\), \(R^{\text{cum}}_t\)dense reward; reward paid before \(t\)sums to 1 or 0dense_rewards, cum_before
\(\alpha_s\), \(\alpha_c\)dampening of the success baseline; weight of completion shaping0.5, 0.5success_alpha, completion_alpha
\(\gamma\), \(\lambda\)discount; GAE mixing0.999, 0.99gamma, gae_lambda
\(K\)frames of the tail correction30value_tail_k
\(w\)freshness weight of an episode's success channelmin(share, 1)w
\(A_t\)final advantage, normalised and clipped[−2, 2]advantage column
Policy improvement
\(\mu\), \(\pi_{\text{ref}}\)behaviour policy: whatever made the datademos + past checkpoints
\(\beta\) (AWR)AWR temperature0.05 in the AWR paper; here \(\sigma\) (§8)beta × global_std
\(p_i\), \(w_i\)draw probability of frame \(i\); its importance weight for the heads\(\propto e^{\operatorname{clip}(A_i,-2,2)}\); \(1/(Np_iT_{\text{ep}})\)priorities, is_weight
\(I\)RECAP indicator; here, "advantage token on"on with prob. \(0.5 + 0.4\min(A/2,1)\) when \(A\ge0\)advantage_mask
\(s\)guidance scale7 or 9cfg_scale
Inference knobs
\(n_e\), \(k\), \(n_a\)executed targets; playback stretch; anchor length5 or 3; 1; 6 or 3actions_to_execute, k_execute, actions_to_keep
\(t_{\text{ip}}\), \(\tau\), \(N\)inpainting onset; noise temperature; candidates0.4–0.5; 0.7–0.9; 2–3time_threshold_inpaint, noise_temperature, num_rollout_candidates
src/lehome_solution/constants.pylines 15, 26ca73828
15ACTION_HORIZON = 30  # 1 second at 30 FPS
      ⋮
26EMA_ALPHA = 0.2  # EMA smoothing for P(success) prediction

Where each idea lives in the code

All paths are in IliaLarchenko/lehome_solution at ca73828, under src/lehome_solution/ unless they start with scripts/ or configs/.

IdeaCodeHere
Flow-matching training targetmodels/pi_modified.py:1223–1229, 1255–1256§3
Sampling, guidance, Euler stepmodels/pi_modified.py:1471, 1616–1619, 1735–1740, 1745§3, §11
Actions as deltas from the joint statetraining/config.py:416–423§3
Checkpoint rewardseval/dataset_writer.py:598–622§6
Gradual first checkpoint (tops)eval/dataset_writer.py:650–661§6
Failure withdrawaleval/dataset_writer.py:668–689§6
Moving average and tail correctioneval/dataset_writer.py:710–723§7
Success-channel GAEeval/dataset_writer.py:803–816§7
Completion shapingeval/dataset_writer.py:866–872§7
Segment baselineseval/dataset_writer.py:1122–1131§7
Segment advantage per stepeval/dataset_writer.py:1190–1205§7
Blend by freshnesseval/dataset_writer.py:1207–1220§7
Global std and clipeval/dataset_writer.py:1238–1242, 1266–1269§7
Precision-boost margintraining/precision_boost.py:89–99§7
AWR samplertraining/data_loader.py:924–932§8
Importance weights for the headstraining/data_loader.py:1553–1559, 1822–1823§8
Dataset share decayscripts/run_rl_pipeline.py:2880–2891§8, §12
RECAP gatemodels/pi_modified.py:359–374§9
AdaRMS advantage vectormodels/pi_modified.py:810–820§9
Best-of-N scoreshared/eval_wrapper.py:214–220§11
Best-of-N redrawshared/eval_wrapper.py:236–242, 252§11
Garment-type warm-upscripts/eval_worker.py:1151–1166§11
Thompson samplingeval/inference_optimization.py:114–120§11
Failure snapshot rulescripts/eval_worker.py:244–247, 1301–1311§12
Stuck detectorconstants.py:32–36§12
Final training settingsconfigs/rl_pipeline_sim.yaml:104–112, 115–119, 135–150, 156–159§7

17Misreadings to avoid

  1. "AWR weights the loss." Not here. The loss is unweighted; the weight decides how often a frame is drawn (§8). Only the value heads use a weight, and that one undoes the tilt.
  2. "The advantage token is a binary threshold, as in π*0.6." It is a random gate: never on for negative advantage, on 50% to 90% of the time for non-negative advantage, more often the higher the advantage (§9).
  3. "Dense reward changes what the policy is rewarded for." Every episode's reward still sums to exactly 1 for a fold and 0 otherwise. The checkpoints only move credit earlier (§6).
  4. "The success head is a separate critic." It is a linear readout of the policy's own representation, from pixels only (§4).
  5. "Best-of-N finds the best chunk." In our run, 55% of plans found every candidate predicted to be worse than expected, and the chosen score was near zero on median. Its value is mostly in avoiding bad chunks (§11).
  6. "30 steps is one second." On the data's clock, yes. In the simulator's physics, 30 steps are a third of a second (§0).
  7. "The episode stops the moment the garment is folded." The checker runs every 30 steps in the code we ran, so the fold is noticed up to 29 steps late (§1).
  8. "Same seed, same result." Our repeat of the same 16 episodes did not reproduce every outcome (§13).
  9. "The real-robot policy uses all of this." The real round dropped advantage conditioning, guidance and best-of-N. It was behaviour cloning on mixed data, with intervention-weighted sampling (§14).

·Sources and checks

  • Ilia Larchenko, Learning to Fold, technical report on the LeHome Challenge 2026 solution, arXiv 2606.27163. Section numbers on this page marked "report §" refer to it.
  • Code: IliaLarchenko/lehome_solution at ca73828, Apache-2.0, with the challenge fork IliaLarchenko/lehome-challenge at 5ea947e. Every code block on this page is copied verbatim from those commits, with file and line numbers.
  • Checkpoint: IliaLarchenko/lehome_sim at 79dcf9e, run on our RTX 4080 SUPER on 2026-10-17, smoke test and repeat. The debug videos are from those runs.
  • X. B. Peng, A. Kumar, G. Zhang, S. Levine, Advantage-Weighted Regression: Simple and Scalable Off-Policy Reinforcement Learning, arXiv 1910.00177. Equation numbers refer to v3.
  • Physical Intelligence, π*0.6: a VLA That Learns From Experience (RECAP), arXiv 2511.14759.
  • Physical Intelligence, π0.5, arXiv 2504.16054.
  • Chelsea Finn, CS224R Deep Reinforcement Learning, Spring 2026, cs224r.stanford.edu: lectures 2 (imitation), 3 (policy gradients), 4 (actor-critic), 7 (offline RL) and 17 (RL for VLAs). Slide numbers are the PDF pages.
  • J. Schulman et al., High-Dimensional Continuous Control Using Generalized Advantage Estimation, ICLR 2016. A. Y. Ng, D. Harada, S. Russell, Policy Invariance Under Reward Transformations, ICML 1999.

What was checked, and how: every hyperparameter on this page was read from the code or the released config. Where the report and the code differ (the control rate, when an episode stops, the fixed advantage of demonstrations), the page follows the code and says so. Numbers marked derived were computed here from stated values. Our run's numbers come from the eval worker's logs.

Not checked: anything about the private garments, the organisers' own evaluation harness, or Ilia's training runs beyond what the report states. No part of training was re-run here.