Download code/snow_rl/estimator.py from iteratehack/snow-rl-baseline: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/iteratehack/snow-rl-baseline/resolve/main/code/snow_rl/estimator.py
- Command line
-
hf download hf://iteratehack/snow-rl-baseline/code/snow_rl/estimator.py
-
curl -L -o estimator.py https://huggingface.co/iteratehack/snow-rl-baseline/resolve/main/code/snow_rl/estimator.py
10.8 kB
| """JAX support-estimate corruption model (Step 3). | |
| Reproduces the *error behaviour* of hackathon-everest's sklearn ExtraTrees estimator so an | |
| MJX/Brax training loop can consume a realistic support signal without ever calling sklearn. | |
| Error structure is measured, not assumed (see scripts/calibrate_from_everest.py): | |
| 1. Regression-to-the-mean shrinkage. The tree ensemble compresses its output range, so | |
| `pred ~= intercept + slope * true`. This is the dominant error term and it is | |
| *systematic* -- a policy cannot average it away the way it can average away noise. | |
| Modelling it as zero-mean Gaussian would make the task substantially easier than reality. | |
| 2. Conditional Gaussian noise around that shrunk value (smaller than the marginal residual | |
| spread, because most of the marginal spread IS the shrinkage). | |
| 3. False-safe events, latched per stance rather than per timestep -- see FALSE_SAFE_NOTE. | |
| 4. Dropout that holds the last valid value and drops confidence. | |
| 5. Per-environment noise scaling so no single calibration is memorised. | |
| All functions are pure and vmap/jit-safe: no data-dependent Python control flow. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| from typing import NamedTuple | |
| import jax | |
| import jax.numpy as jnp | |
| CALIBRATION_PATH = Path(__file__).with_name("calibration.json") | |
| DEPTH_NORM_M = 0.25 # RL terrain range 0..0.25 m | |
| SUPPORT_NORM_N = 900.0 # material_priors bearing_capacity_n upper bound | |
| N_FEET = 2 | |
| N_CHANNELS = 4 # depth_est, support_est, confidence, void_prob | |
| OBS_SIZE = N_FEET * N_CHANNELS | |
| # Fitted over the terrain generator's own distribution (see scripts/fit_support_to_depth.py). | |
| # Valid only while bearing is derived from the sinkage law; regenerate if that changes. | |
| SUPPORT_TO_DEPTH_SLOPE = -3.244e-4 | |
| SUPPORT_TO_DEPTH_INTERCEPT = 0.1833 | |
| # Chosen by measurement, and RE-measured whenever the calibration contract changes. | |
| # Under the real 10-channel rig: corr(implied, true depth) peaks at 0.691 with w=0.50 | |
| # (0.683 at 0.75, 0.664 depth-only, 0.327 support-only). Under the optimistic 19-channel | |
| # contract the optimum was 0.75 at corr 0.776 -- the real rig costs ~0.09 of correlation. | |
| # Support contributes depth information mainly in shallow terrain: past the failure sinkage | |
| # the substrate never carries load, so bearing stops distinguishing 12 cm from 25 cm. | |
| DEPTH_CHANNEL_WEIGHT = 0.50 | |
| FALSE_SAFE_NOTE = """\ | |
| everest measures two very different false-safe rates on the same held-out split: | |
| 0.005 when the consumer sees the CONSERVATIVE lower bound (mean - 2*sigma) | |
| 0.303 when the consumer sees the POINT estimate | |
| The brief's observation block hands the policy a point estimate plus a separate confidence, | |
| which puts the honest rate near 0.303, not 0.005. Applying 0.005 would make the environment | |
| ~60x more forgiving than the estimator it claims to model. Default here is the point-estimate | |
| rate; set `false_safe_rate` explicitly to override.""" | |
| class Channel(NamedTuple): | |
| slope: float | |
| intercept: float | |
| sigma: float | |
| class EstimatorParams(NamedTuple): | |
| depth: Channel | |
| support: Channel | |
| false_safe_rate: float | |
| false_safe_support_n: float # what a false-safe estimate reports | |
| dropout_prob: float | |
| noise_scale_range: tuple[float, float] | |
| confidence_resid_corr: float | |
| void_false_positive_rate: float | |
| void_false_negative_rate: float | |
| class EstimatorState(NamedTuple): | |
| """Per-environment carry. Leading axis is the env batch under vmap.""" | |
| last_obs: jax.Array # (N_FEET, N_CHANNELS) held values for dropout | |
| false_safe: jax.Array # (N_FEET,) bool, latched for the current stance | |
| noise_scale: jax.Array # () per-env multiplier on all sigmas | |
| def load_params( | |
| path: Path | str = CALIBRATION_PATH, | |
| *, | |
| false_safe_rate: float | None = None, | |
| dropout_prob: float = 0.02, | |
| void_false_positive_rate: float = 0.05, | |
| void_false_negative_rate: float = 0.10, | |
| ) -> EstimatorParams: | |
| """Build params from measured calibration. | |
| void_* rates default to non-zero on purpose. everest's void classifier scores a perfect | |
| 1.0/1.0/0.0 on held-out data, which would hand the policy an oracle for voids and quietly | |
| violate the "rewards never key off ground truth" constraint. Injecting realistic error | |
| keeps void_prob an estimate rather than a truth channel. | |
| """ | |
| cal = json.loads(Path(path).read_text()) | |
| ch = cal["channels"] | |
| fs = cal["false_safe"] | |
| rate = fs["rate_point_estimate"] if false_safe_rate is None else false_safe_rate | |
| return EstimatorParams( | |
| depth=Channel(ch["depth"]["shrink_slope"], ch["depth"]["shrink_intercept"], | |
| ch["depth"]["conditional_sigma"]), | |
| support=Channel(ch["support"]["shrink_slope"], ch["support"]["shrink_intercept"], | |
| ch["support"]["conditional_sigma"]), | |
| false_safe_rate=float(rate), | |
| false_safe_support_n=float(fs["unsafe_threshold_n"] * 1.25), | |
| dropout_prob=float(dropout_prob), | |
| noise_scale_range=(0.7, 1.4), | |
| confidence_resid_corr=float(ch["support"]["uncertainty_resid_corr"]), | |
| void_false_positive_rate=float(void_false_positive_rate), | |
| void_false_negative_rate=float(void_false_negative_rate), | |
| ) | |
| def init_state(key: jax.Array, params: EstimatorParams) -> EstimatorState: | |
| """Initialise one environment's carry. vmap over keys for a batch.""" | |
| lo, hi = params.noise_scale_range | |
| return EstimatorState( | |
| last_obs=jnp.zeros((N_FEET, N_CHANNELS)), | |
| false_safe=jnp.zeros((N_FEET,), dtype=bool), | |
| noise_scale=jax.random.uniform(key, (), minval=lo, maxval=hi), | |
| ) | |
| def _shrink(true_value: jax.Array, ch: Channel) -> jax.Array: | |
| return ch.intercept + ch.slope * true_value | |
| def estimate( | |
| key: jax.Array, | |
| state: EstimatorState, | |
| true_depth_m: jax.Array, # (N_FEET,) | |
| true_support_n: jax.Array, # (N_FEET,) | |
| true_void: jax.Array, # (N_FEET,) bool | |
| new_stance: jax.Array, # (N_FEET,) bool -- foot just made contact | |
| params: EstimatorParams, | |
| ) -> tuple[jax.Array, EstimatorState]: | |
| """One 50 Hz estimator tick for one environment. | |
| Returns (obs, state) where obs is (OBS_SIZE,) laid out per foot as | |
| [depth_est_norm, support_est_norm, confidence, void_prob]. | |
| """ | |
| k_d, k_s, k_fs, k_drop, k_conf, k_void = jax.random.split(key, 6) | |
| scale = state.noise_scale | |
| depth = _shrink(true_depth_m, params.depth) + params.depth.sigma * scale * jax.random.normal( | |
| k_d, true_depth_m.shape | |
| ) | |
| support = _shrink(true_support_n, params.support) + params.support.sigma * scale * jax.random.normal( | |
| k_s, true_support_n.shape | |
| ) | |
| # False-safe: resampled only on a NEW STANCE, then held for that stance's duration. | |
| # Sampling per 50 Hz timestep instead would fire ~20x per 0.4 s stance and inflate the | |
| # effective per-footstep rate by more than an order of magnitude. | |
| unsafe = true_support_n < params.false_safe_support_n / 1.25 | |
| draw = jax.random.uniform(k_fs, true_support_n.shape) < params.false_safe_rate | |
| fresh = jnp.where(new_stance, unsafe & draw, state.false_safe) | |
| support = jnp.where(fresh, params.false_safe_support_n, support) | |
| # Confidence: weakly informative, matching the measured |resid| vs uncertainty correlation. | |
| # A false-safe estimate looks CONFIDENT -- that is what makes it dangerous. | |
| resid_proxy = jnp.abs(support - _shrink(true_support_n, params.support)) / ( | |
| params.support.sigma * 3.0 | |
| ) | |
| # A false-safe is exactly the case where the tree ensemble AGREES and is wrong -- tight | |
| # spread is why it cleared everest's 2-sigma gate. So it reports near-zero apparent | |
| # residual, i.e. high confidence. That is what makes it dangerous rather than merely noisy. | |
| resid_proxy = jnp.where(fresh, 0.0, resid_proxy) | |
| noise_c = jax.random.uniform(k_conf, true_support_n.shape, minval=-0.3, maxval=0.3) | |
| rho = params.confidence_resid_corr | |
| confidence = jnp.clip(1.0 - rho * resid_proxy + noise_c, 0.0, 1.0) | |
| flip = jax.random.uniform(k_void, true_void.shape) | |
| void_prob = jnp.where( | |
| true_void, | |
| jnp.where(flip < params.void_false_negative_rate, 0.15, 0.85), | |
| jnp.where(flip < params.void_false_positive_rate, 0.75, 0.10), | |
| ) | |
| obs = jnp.stack( | |
| [ | |
| jnp.clip(depth / DEPTH_NORM_M, 0.0, 1.5), | |
| jnp.clip(support / SUPPORT_NORM_N, 0.0, 1.5), | |
| confidence, | |
| void_prob, | |
| ], | |
| axis=-1, | |
| ) # (N_FEET, N_CHANNELS) | |
| # Dropout: hold the last valid sample and collapse confidence. | |
| dropped = jax.random.uniform(k_drop, (N_FEET,)) < params.dropout_prob | |
| held = state.last_obs.at[:, 2].multiply(0.3) | |
| obs = jnp.where(dropped[:, None], held, obs) | |
| return obs.reshape(OBS_SIZE), EstimatorState( | |
| last_obs=obs, false_safe=fresh, noise_scale=scale | |
| ) | |
| def deshrink(value: jax.Array, ch: Channel) -> jax.Array: | |
| """Invert the measured shrinkage to recover a correctly-SCALED estimate. | |
| The raw estimate is compressed toward the training mean (depth slope 0.115), so as an | |
| absolute number it is worse than a constant (R^2 -0.06) even though it still TRACKS truth | |
| (corr +0.45). A learned policy could rescale such an input itself, but a fixed reward | |
| formula cannot -- `depth_est + 0.05` would be a near-constant target. | |
| De-shrinking restores the scale at the cost of amplifying noise by 1/slope. The result is | |
| noisy but unbiased and correctly scaled, which is what a reward target needs. | |
| Note this is only legitimate because the shrinkage was MEASURED on held-out data. It uses | |
| no per-timestep ground truth and is applied to the corrupted estimate, so it does not leak. | |
| """ | |
| return (value - ch.intercept) / ch.slope | |
| def implied_depth_m(obs: jax.Array, params: EstimatorParams) -> jax.Array: | |
| """Clearance-relevant snow depth implied by the estimator block, per foot. | |
| Blends the de-shrunk depth channel with the depth implied by support. In terrain where | |
| bearing is derived from the sinkage law the two are coupled (corr -0.50), so support | |
| carries independent information about how deep the snow is. | |
| """ | |
| depth_raw = obs[..., 0] * DEPTH_NORM_M | |
| support_raw = obs[..., 1] * SUPPORT_NORM_N | |
| from_depth = deshrink(depth_raw, params.depth) | |
| from_support = SUPPORT_TO_DEPTH_SLOPE * deshrink(support_raw, params.support) + SUPPORT_TO_DEPTH_INTERCEPT | |
| blended = DEPTH_CHANNEL_WEIGHT * from_depth + (1.0 - DEPTH_CHANNEL_WEIGHT) * from_support | |
| return jnp.clip(blended, 0.0, DEPTH_NORM_M) | |
| batched_estimate = jax.vmap(estimate, in_axes=(0, 0, 0, 0, 0, 0, None)) | |
| batched_init = jax.vmap(init_state, in_axes=(0, None)) | |