File size: 10,779 Bytes
2eeee5a 08d8fb9 4e49627 2eeee5a 4e49627 2eeee5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | """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))
|