snow-rl-baseline / code /snow_rl /warmstart.py
AmanC007's picture
Upload folder using huggingface_hub
d55913c verified
Raw
History Blame Contribute Delete
7.86 kB
"""Warm-start surgery: widen a flat-ground checkpoint to fit the snow policy.
Step 1 trains stock `G1JoystickFlatTerrain`, whose actor sees 103 observations. The snow env
adds three blocks -- 24 estimator, 24 belief-map readout, 5 bilateral reserve -- so the actor
sees 156 and the critic 273. The first-layer weight matrices therefore differ:
actor (103, 512) -> (156, 512)
critic (216, 512) -> (273, 512)
Brax will not reconcile that. Loading the baseline directly either errors or silently
reinitialises, and silent reinitialisation is the dangerous outcome: training proceeds, curves
look plausible, and the warm start -- the entire reason Step 5 is affordable -- did nothing.
The fix is to **zero-pad the new input rows**. Zeros mean the expanded policy is initially
*exactly* the baseline: the new channels are multiplied by zero and cannot affect the output.
PPO then grows those weights from zero as the channels prove useful. That gives a clean
reading of the experiment -- the policy starts by ignoring the sensor and has to learn to use
it, rather than starting from a random dependence on it.
The observation normaliser needs the same treatment, and is easy to get wrong. It stores
mean, std and summed_variance per observation key. New entries are padded with mean 0 and
summed_variance equal to `count`, so the derived std is exactly 1 and the new channels pass
through unscaled. Our added blocks are already roughly unit-scaled by construction.
Layers after the first are untouched -- only the input width changes.
"""
from __future__ import annotations
from typing import Any
import jax
import jax.numpy as jnp
STATE_KEY = "state"
PRIVILEGED_KEY = "privileged_state"
def _scalar(value: Any) -> float:
"""Coerce a running-statistics count to a float.
A checkpoint round-tripped through orbax returns `count` as a `UInt64(hi, lo)` wrapper
rather than a numeric scalar, and `float()` on it raises. A synthetic in-memory fixture
never shows this -- only a checkpoint actually written to disk and read back does.
"""
try:
return float(value)
except (TypeError, ValueError):
pass
if hasattr(value, "hi") and hasattr(value, "lo"):
return float((int(value.hi) << 32) | int(value.lo))
return float(jnp.asarray(value).item())
def _pad_rows(kernel: jax.Array, target_rows: int) -> jax.Array:
"""Grow a (in, out) weight matrix to (target_rows, out), new rows zeroed."""
current = kernel.shape[0]
if current == target_rows:
return kernel
if current > target_rows:
raise ValueError(
f"checkpoint first layer has {current} inputs but the target env expects "
f"{target_rows}; shrinking is not supported"
)
return jnp.concatenate(
[kernel, jnp.zeros((target_rows - current, kernel.shape[1]), kernel.dtype)], axis=0
)
def _expand_first_layer(params: Any, target_rows: int) -> Any:
"""Zero-pad `hidden_0`'s kernel. Every later layer is unaffected."""
inner = params.get("params", params)
kernel = inner["hidden_0"]["kernel"]
padded = _pad_rows(kernel, target_rows)
new_inner = dict(inner)
new_inner["hidden_0"] = dict(inner["hidden_0"])
new_inner["hidden_0"]["kernel"] = padded
if "params" in params:
out = dict(params)
out["params"] = new_inner
return out
return new_inner
def _expand_normaliser(norm: Any, targets: dict[str, int]) -> Any:
"""Pad running statistics so new channels start at mean 0, std 1.
summed_variance is padded with `count` rather than zero, because brax derives
std = sqrt(summed_variance / count). Padding with zeros would give std 0 and produce
divide-by-zero or infinite normalised values on the new channels.
"""
count = _scalar(norm.count)
def pad(tree, fill):
out = {}
for key, value in tree.items():
target = targets.get(key)
if target is None or value.shape[-1] == target:
out[key] = value
continue
extra = target - value.shape[-1]
if extra < 0:
raise ValueError(
f"normaliser '{key}' has {value.shape[-1]} entries but the target env "
f"expects {target}; shrinking is not supported"
)
out[key] = jnp.concatenate(
[value, jnp.full((extra,), fill, value.dtype)], axis=-1
)
return out
return norm.replace(
mean=pad(norm.mean, 0.0),
std=pad(norm.std, 1.0),
summed_variance=pad(norm.summed_variance, count),
)
def expand_params(params: Any, actor_obs_size: int, critic_obs_size: int) -> Any:
"""Widen a saved brax PPO checkpoint to a larger observation.
Accepts either the 2-tuple `(normaliser, policy)` used for inference or the 3-tuple
`(normaliser, policy, value)` from a training checkpoint, and returns the same shape.
"""
if not isinstance(params, (tuple, list)) or len(params) not in (2, 3):
raise TypeError(
f"expected a (normaliser, policy[, value]) tuple, got {type(params).__name__}"
)
targets = {STATE_KEY: actor_obs_size, PRIVILEGED_KEY: critic_obs_size}
normaliser = _expand_normaliser(params[0], targets)
policy = _expand_first_layer(params[1], actor_obs_size)
if len(params) == 2:
return (normaliser, policy)
value = _expand_first_layer(params[2], critic_obs_size)
return (normaliser, policy, value)
def expand_for_env(params: Any, env) -> Any:
"""Widen a baseline checkpoint to fit a snow env's observation sizes."""
return expand_params(
params,
actor_obs_size=int(env.observation_size[STATE_KEY][0]),
critic_obs_size=int(env.observation_size[PRIVILEGED_KEY][0]),
)
def added_channel_slice(baseline_actor_size: int, env) -> slice:
"""Where the added blocks sit in the actor observation.
Useful for asserting that a freshly expanded policy ignores them.
"""
return slice(baseline_actor_size, int(env.observation_size[STATE_KEY][0]))
def assert_behaviourally_identical(
baseline_apply,
expanded_apply,
baseline_params,
expanded_params,
baseline_obs: dict[str, jax.Array],
expanded_obs: dict[str, jax.Array],
tolerance: float = 1e-5,
) -> float:
"""Check the expansion changed nothing, and return the max deviation.
The whole point of zero-padding is that the expanded policy reproduces the baseline
exactly on any observation whose leading entries match. If this fails, the warm start is
not a warm start.
"""
a = baseline_apply(baseline_params, baseline_obs)
b = expanded_apply(expanded_params, expanded_obs)
deviation = float(jnp.max(jnp.abs(jnp.asarray(a) - jnp.asarray(b))))
if deviation > tolerance:
raise AssertionError(
f"expanded policy deviates from the baseline by {deviation:.2e} "
f"(tolerance {tolerance:.0e}); the warm start would not be faithful"
)
return deviation
def summarise(params: Any, env) -> dict[str, Any]:
"""Human-readable report of what an expansion will do."""
norm, policy = params[0], params[1]
actor_now = policy["params"]["hidden_0"]["kernel"].shape[0]
actor_target = int(env.observation_size[STATE_KEY][0])
out = {
"actor_inputs": (actor_now, actor_target, actor_target - actor_now),
"normaliser_state": (
int(jax.tree_util.tree_leaves(norm.mean[STATE_KEY])[0].shape[-1]), actor_target
),
}
if len(params) == 3:
critic_now = params[2]["params"]["hidden_0"]["kernel"].shape[0]
critic_target = int(env.observation_size[PRIVILEGED_KEY][0])
out["critic_inputs"] = (critic_now, critic_target, critic_target - critic_now)
return out