twanghcmut's picture
download
raw
35.8 kB
"""Query construction for the graph retriever's training curriculum.
The pretext task (make_queries), hard-negative mining (NegativeMiner), the multi-positive
node-identity target (SoftTargetBuilder), and the ENTRY_STATIC pool cache. Design writeup:
docs/technical/02-retrieval-head.md.
"""
from __future__ import annotations
import warnings
from typing import NamedTuple
import numpy as np
from onf.graph.core import schema
from onf.graph.core.geometry import leave_one_demo_out_floors
from onf.graph.train.drift import PANDA_JOINT_HI, PANDA_JOINT_LO
from onf.graph.core.nodes import NodeTable
from onf.graph.core.geometry import CleanlinessScorer, edge_pad_hist, finite_diff_vel, off_manifold_batch
from onf.graph.train.types import QuerySpec
# ---- curriculum hyper-parameters -- see docs/technical/02-retrieval-head.md's constants table.
N_NEG = 16 # mined hard negatives per query, split across categories (a)/(b)/(c)
SELFX_K = 16 # local kNN pool mine_negatives searches for the self-intersection bucket (b)
SELFX_PHASE_GAP = 0.15 # |phase| gap that promotes a near-in-q neighbour into bucket (b)
# ---- episode-start (entry) query classes ------------------------------------------------------------
ENTRY_FRAC = 1.0 / 3.0 # share of make_queries' queries that are episode-start (entry) queries
ENTRY_LO_MULT = 0.25 # entry perturbation radius floor: 0.25 * demo-start noise floor p50
ENTRY_HI_MULT = 50.0 # entry perturbation radius ceiling: 50 * demo-start noise floor p99
ENTRY_LOGUNIFORM = True # sample the radius log-uniformly -- equal density per decade
# ---- ENTRY_STATIC: trains the deployed t=0 input shape (a single perturbed frame repeated across the
# window, IDENTICALLY ZERO finite-differenced velocity), which the ENTRY class never produces.
ENTRY_STATIC_FRAC = 0.15 # share of ENTRY queries that are ENTRY_STATIC instead
ENTRY_STATIC_BAND = 0.05 # phase band for the static class's soft target pool -- matches the
# deployed GR_ENTRY_BAND constant
ENTRY_STATIC_NODE_W = 1.0 # absolute weight of the node-identity term on ENTRY_STATIC rows, replacing
# cfg.node_w (the phase terms are trivial: the whole pool sits in bin 0)
ENTRY_STATIC_SRC_AUX_W = 0.0 # zeroed on ENTRY_STATIC rows -- the source-node warm-up would sharpen mass
# onto the row's own demo-start node, fighting the uniform-over-pool target
# ---- DRIFT: the only class with a clean-to-perturbed breakpoint INSIDE the window. Every other class
# is uniformly clean (TRAVERSAL) or uniformly perturbed (ENTRY/ENTRY_STATIC), so "which rows of this
# history are still trustworthy" is a question they never pose.
DRIFT_FRAC = 0.15 # share of the rows the static AND entry draws both declined
DRIFT_MIN_HIST = 3 # >= 1 clean row and >= 2 perturbed ones, so the offset always ramps over
# a finite-difference step instead of landing as a single-frame impulse
def _node_at_raw(nodes: NodeTable, owner: int, raw_idx: int) -> int:
"""The node on demo owner whose raw span contains raw_idx.
Last node with t_raw <= raw_idx, clamped to the demo's own node range;
onf.graph.core.nodes.NodeTable.node_at_raw is the vectorised canonical form.
Args:
nodes: Node table to look up in.
owner: Demo index.
raw_idx: Raw frame index.
Returns:
The node id.
"""
node_ids = np.asarray(nodes.demo_nodes(int(owner)))
t_raws = np.asarray(nodes.t_raw)[node_ids]
pos = int(np.searchsorted(t_raws, int(raw_idx), side="right")) - 1
pos = min(max(pos, 0), len(node_ids) - 1)
return int(node_ids[pos])
# There is exactly ONE finite-difference velocity implementation, onf.graph.core.geometry.
# finite_diff_vel, shared by every query builder here AND by GraphRetriever.retrieve: two
# reimplementations with different row-0 conventions once produced two different e_Q for the same
# window depending on train-vs-deploy, one of the divergences behind the abstain-collapse bug.
def entry_perturbation_floor(nodes: NodeTable) -> dict[str, float]:
"""Leave-one-demo-out 1-NN distance (rad) from each demo's first raw frame to every other demo's.
The same procedure onf.field.build.build_qmanifold_full uses for the Sentinel's noise-floor tau,
specialised to t=0 points, since that is what an episode-start perturbation is calibrated
against. Purely a function of the corpus on nodes -- no simulator, no external constant.
Args:
nodes: Node table providing raw_ptr and q_raw.
Returns:
{"p50": ..., "p90": ..., "p99": ...} in radians.
Raises:
ValueError: If the corpus has fewer than two demos.
"""
raw_ptr = np.asarray(nodes.raw_ptr, dtype=np.int64)
q_raw = np.asarray(nodes.q_raw, dtype=np.float64)
j_demos = len(raw_ptr) - 1
if j_demos < 2:
raise ValueError("entry_perturbation_floor: need >= 2 demos for a leave-one-demo-out floor")
starts = q_raw[raw_ptr[:-1]] # [J, D] -- each demo's t=0 config
owner_raw = np.repeat(np.arange(j_demos, dtype=np.int64), np.diff(raw_ptr))
own_ids = np.arange(j_demos, dtype=np.int64)
floors = leave_one_demo_out_floors(starts, own_ids, q_raw, owner_raw)
return {
"p50": float(np.percentile(floors, 50)), "p90": float(np.percentile(floors, 90)),
"p99": float(np.percentile(floors, 99)),
}
def measure_inter_demo_spacing(nodes: NodeTable) -> float:
"""The corpus's own median leave-one-demo-out nearest-neighbour distance (rad) between nodes.
This is the abstain decision's consequence-label threshold (docs/technical/02-retrieval-head.md, "The
abstain decision"): a query whose most recent frame sits closer than this to any node is within
one demo-spacing of genuine support, so abstaining is correct; farther, recovery is correct.
Computed once per graph by train_graph. Measured ~0.054 rad on the real long-suite graph.
Args:
nodes: Node table providing q and owner.
Returns:
The median floor, in radians.
Raises:
ValueError: If the corpus has fewer than two demos.
"""
if nodes.n_demos < 2:
raise ValueError("measure_inter_demo_spacing: need >= 2 demos for a leave-one-demo-out floor")
q = np.asarray(nodes.q, dtype=np.float64)
owner = np.asarray(nodes.owner, dtype=np.int64)
floors = leave_one_demo_out_floors(q, owner, q, owner)
return float(np.median(floors))
def _entry_perturb(
q0: np.ndarray, rng: np.random.RandomState, dim: int, sigma_range: tuple[float, float],
) -> np.ndarray:
"""q0 -> q0 + a random-direction offset of radius drawn from sigma_range, clipped to the limits.
Args:
q0: [dim] unperturbed configuration.
rng: Random generator, consuming a direction draw then a radius draw.
dim: Degrees of freedom.
sigma_range: (lo, hi) radius range in radians.
Returns:
[dim] perturbed configuration.
"""
direction = rng.randn(dim)
n = np.linalg.norm(direction)
direction = direction / n if n > 1e-12 else direction
lo_r, hi_r = float(sigma_range[0]), float(sigma_range[1])
# Log-uniform: over a two-decade range a linear draw puts ~90% of its mass in the top decade and
# starves the small-offset end, which is exactly the boundary the abstain decision has to learn.
radius = float(np.exp(rng.uniform(np.log(max(lo_r, 1e-6)), np.log(max(hi_r, 1e-6))))) \
if ENTRY_LOGUNIFORM and hi_r > lo_r > 0 else float(rng.uniform(lo_r, hi_r))
lo, hi = PANDA_JOINT_LO[:dim], PANDA_JOINT_HI[:dim]
return np.clip(np.asarray(q0, np.float64) + radius * direction, lo, hi)
def _node_feature_matrix(nodes: NodeTable) -> np.ndarray:
"""[q, qdot, grip, t_frac] raw columns -- GraphRetrieverNet.node_encode's expected input.
NOT NodeTable.features, which already applies psi() and feeds the ONF field trunk instead.
Args:
nodes: Node table to read.
Returns:
[V, 2*dim+2] float32 feature matrix.
"""
grip = np.asarray(nodes.grip, dtype=np.float32)[:, None]
t_frac = np.asarray(nodes.t_frac, dtype=np.float32)[:, None]
return np.concatenate([nodes.q, nodes.qdot, grip, t_frac], axis=1).astype(np.float32)
class _QueryRow(NamedTuple):
"""One row of make_queries output, as returned by each per-class row-builder.
Attributes:
q_hist: [window, dim] float32 joint-position window.
src_owner: Demo the window was drawn from.
src_node: Node at the window's own end, T.
tgt_node: Node at T+advance -- the retrieval label.
t_raw_now: The window's own current RAW frame, T. Not recoverable from src_node, whose
t_raw is up to coarsen-1 frames behind it.
is_clean: Provenance: True for TRAVERSAL, False for the perturbed classes.
is_entry_static: Whether this row is an ENTRY_STATIC row.
is_drift: Whether this row is a DRIFT row.
break_row: First perturbed row of the window, or -1 when every row is clean.
perturb_radius: Realised (post-clip) offset radius at the window's end, rad; 0 when clean.
"""
q_hist: np.ndarray
src_owner: int
src_node: int
tgt_node: int
t_raw_now: int
is_clean: bool
is_entry_static: bool
is_drift: bool
break_row: int
perturb_radius: float
class _QueryCorpus(NamedTuple):
"""The per-call corpus context shared by the three row-builders.
Attributes:
nodes: The node table.
raw_ptr: [J+1] per-demo boundaries into the raw arrays.
q_raw: [N_raw, dim] float32 raw joint configs.
dim: Degrees of freedom.
window: This call's window length.
"""
nodes: NodeTable
raw_ptr: np.ndarray
q_raw: np.ndarray
dim: int
window: int
def _entry_static_row(
corpus: _QueryCorpus, rng: np.random.RandomState, pool_entry: np.ndarray,
entry_sigma_range: tuple[float, float],
) -> _QueryRow:
"""Build one ENTRY_STATIC row -- see make_queries.
Args:
corpus: The per-call corpus context.
rng: Random generator; draws rng.randint (pick demo) then _entry_perturb's own draws.
pool_entry: Demos long enough to serve as entry queries.
entry_sigma_range: (lo, hi) perturbation radius range.
Returns:
The row.
"""
nodes, raw_ptr, q_raw, dim, window = corpus
j = int(pool_entry[rng.randint(len(pool_entry))])
lo = int(raw_ptr[j])
q0 = q_raw[lo].astype(np.float64)
q0_pert = _entry_perturb(q0, rng, dim, entry_sigma_range)
# Every row of this window is a copy of the SAME perturbed frame, so finite_diff_vel yields
# identically zero velocity -- the exact shape a live t=0 call's repeat(q0, hist) produces.
q_hist_row = np.repeat(q0_pert[None, :].astype(np.float32), window, axis=0)
tgt = _node_at_raw(nodes, j, lo)
return _QueryRow(q_hist=q_hist_row, src_owner=j, src_node=tgt, tgt_node=tgt, t_raw_now=lo,
is_clean=False, is_entry_static=True, is_drift=False, break_row=0,
perturb_radius=float(np.linalg.norm(q0_pert - q0)))
def _entry_row(
corpus: _QueryCorpus, rng: np.random.RandomState, pool_entry: np.ndarray, advance: int,
entry_sigma_range: tuple[float, float],
) -> _QueryRow:
"""Build one ENTRY row -- see make_queries.
Args:
corpus: The per-call corpus context.
rng: Random generator; draws rng.randint (pick demo), _entry_perturb's own draws, then
rng.randint (window length).
pool_entry: Demos long enough to serve as entry queries.
advance: Raw frames from the window end to the target.
entry_sigma_range: (lo, hi) perturbation radius range.
Returns:
The row.
"""
nodes, raw_ptr, q_raw, dim, window = corpus
j = int(pool_entry[rng.randint(len(pool_entry))])
lo = int(raw_ptr[j])
demo_hi = int(raw_ptr[j + 1])
q0 = q_raw[lo].astype(np.float64)
q0_pert = _entry_perturb(q0, rng, dim, entry_sigma_range)
delta = q0_pert - q0 # the REALIZED constant offset (post-clip)
w_use = min(int(rng.randint(1, window + 1)), demo_hi - lo) # same W distribution as TRAVERSAL
t_raw_end = lo + w_use - 1 # window's OWN (current) end
raw_window = q_raw[lo : lo + w_use].astype(np.float64) + delta[None, :]
raw_window = np.clip(raw_window, PANDA_JOINT_LO[:dim], PANDA_JOINT_HI[:dim])
q_hist_row = edge_pad_hist(raw_window.astype(np.float32), window)[0]
return _QueryRow(
q_hist=q_hist_row, src_owner=j, src_node=_node_at_raw(nodes, j, t_raw_end),
tgt_node=_node_at_raw(nodes, j, t_raw_end + advance), t_raw_now=t_raw_end, is_clean=False,
is_entry_static=False, is_drift=False, break_row=0,
perturb_radius=float(np.linalg.norm(delta)),
)
def _drift_row(
corpus: _QueryCorpus, rng: np.random.RandomState, pool_trav: np.ndarray, advance: int,
entry_sigma_range: tuple[float, float],
) -> _QueryRow:
"""Build one DRIFT row -- see make_queries.
Args:
corpus: The per-call corpus context.
rng: Random generator; draws rng.randint (pick demo), rng.randint (T), rng.randint (window
length), rng.randint (breakpoint), then _entry_perturb's own draws.
pool_trav: Demos long enough for a full window plus advance.
advance: Raw frames from the window end to the target.
entry_sigma_range: (lo, hi) perturbation radius range.
Returns:
The row.
"""
nodes, raw_ptr, q_raw, dim, window = corpus
j = int(pool_trav[rng.randint(len(pool_trav))])
lo, hi = int(raw_ptr[j]), int(raw_ptr[j + 1])
t_raw_end = lo + int(rng.randint(window - 1, (hi - lo) - advance)) # same T draw as TRAVERSAL
w_use = int(rng.randint(DRIFT_MIN_HIST, window + 1))
n_clean = int(rng.randint(1, w_use - 1)) # leaves >= 2 perturbed rows
raw_window = q_raw[t_raw_end - w_use + 1 : t_raw_end + 1].astype(np.float64)
delta = _entry_perturb(raw_window[-1], rng, dim, entry_sigma_range) - raw_window[-1]
# A RAMP to the full radius at T, not a step at the breakpoint: a step adds a one-frame |delta|
# impulse to the finite-differenced velocity that no deployed window contains, while the ramp
# leaves a sustained per-row bias -- what an arm executing a wrong action chunk actually does.
ramp = np.arange(1, w_use - n_clean + 1, dtype=np.float64) / (w_use - n_clean)
drifted = raw_window.copy()
drifted[n_clean:] += ramp[:, None] * delta[None, :]
drifted = np.clip(drifted, PANDA_JOINT_LO[:dim], PANDA_JOINT_HI[:dim])
q_hist_row = edge_pad_hist(drifted.astype(np.float32), window)[0]
return _QueryRow(
q_hist=q_hist_row, src_owner=j, src_node=_node_at_raw(nodes, j, t_raw_end),
tgt_node=_node_at_raw(nodes, j, t_raw_end + advance), t_raw_now=t_raw_end, is_clean=False,
is_entry_static=False, is_drift=True,
# edge_pad_hist right-aligns, so a w_use-row window's own row i sits at window - w_use + i.
break_row=window - w_use + n_clean, perturb_radius=float(np.linalg.norm(delta)),
)
def _traversal_row(corpus: _QueryCorpus, rng: np.random.RandomState, pool_trav: np.ndarray, advance: int) -> _QueryRow:
"""Build one TRAVERSAL row -- see make_queries.
Args:
corpus: The per-call corpus context.
rng: Random generator; draws rng.randint (pick demo), rng.randint (T), rng.randint (window
length).
pool_trav: Demos long enough for a full window plus advance.
advance: Raw frames from the window end to the target.
Returns:
The row.
"""
nodes, raw_ptr, q_raw, _dim, window = corpus
j = int(pool_trav[rng.randint(len(pool_trav))])
lo, hi = int(raw_ptr[j]), int(raw_ptr[j + 1])
length = hi - lo
t_local = int(rng.randint(window - 1, length - advance)) # T, local to this demo
t_raw_end = lo + t_local
w_use = int(rng.randint(1, window + 1)) # variable true history length
start = t_raw_end - w_use + 1
raw_window = q_raw[start : t_raw_end + 1]
q_hist_row = edge_pad_hist(raw_window, window)[0]
return _QueryRow(
q_hist=q_hist_row, src_owner=j, src_node=_node_at_raw(nodes, j, t_raw_end),
tgt_node=_node_at_raw(nodes, j, t_raw_end + advance), t_raw_now=t_raw_end, is_clean=True,
is_entry_static=False, is_drift=False, break_row=-1, perturb_radius=0.0,
)
def make_queries(
nodes: NodeTable, rng: np.random.RandomState, n: int, owners: np.ndarray, spec: QuerySpec | None = None,
) -> dict[str, np.ndarray]:
"""Draw n training queries from demos owned by owners, four classes mixed per spec.
TRAVERSAL (the rows no other class claims) is the primary pretext task: a window of up
to spec.window clean raw frames ending at T, labelled with the node advance raw frames further
along the SAME demo. ENTRY is W real consecutive frames from a demo's own start, offset by a
single constant perturbation and clipped to the joint limits, labelled advance frames past the
window's own unperturbed end. ENTRY_STATIC is the deployed t=0 shape: one perturbed t=0 frame
repeated across the window, so its finite-differenced velocity is identically zero, labelled
with the demo's own t=0 node. DRIFT is a mid-demo window whose first rows are clean and whose
remaining rows ramp out to a perturbation of the same log-uniform radius -- the only class with
a breakpoint inside the window. See docs/technical/02-retrieval-head.md for why each is shaped this way.
Args:
nodes: Node table to draw from.
rng: Random generator driving the class mix and every row builder.
n: Number of queries.
owners: Demo indices eligible for this set.
spec: Keyword options; None is exactly QuerySpec(), which reproduces the original
bare-default call.
Returns:
A dict of length-n arrays: q_hist [n,window,D] f32, qdot_hist [n,window,D] f32
(onf.graph.core.geometry.finite_diff_vel), grip_hist [n,window] f32 (broadcast from the
query's own T node -- raw per-frame grip is not persisted), w_hist [n,window] f32
cleanliness weights, src_owner [n] i32, src_node [n] i64 (the T node), tgt_node [n] i64
(the T+advance node, the retrieval label; advance=0 makes it the T node itself),
t_raw_now [n] i64 (the T RAW frame), is_clean
[n] bool (provenance: which branch built the window), abstain_is_correct [n] bool (the
abstain LOSS/eval label, by consequence not provenance), is_entry_static [n] bool,
is_drift [n] bool, break_row [n] i32 (first perturbed window row, -1 when clean),
perturb_radius [n] f32 (realised offset radius at T, rad).
Raises:
ValueError: If owners is empty, no demo is long enough for the traversal queries the
requested class mix still needs, or DRIFT is requested on a window too short to hold a
breakpoint.
"""
spec = spec if spec is not None else QuerySpec()
field = spec.field
advance = schema.ADVANCE if spec.advance is None else spec.advance
window = schema.HIST_H if spec.window is None else spec.window
entry_frac = ENTRY_FRAC if spec.entry_frac is None else spec.entry_frac
entry_sigma_range = spec.entry_sigma_range
inter_demo_spacing = spec.inter_demo_spacing
entry_static_frac = spec.entry_static_frac
drift_frac = float(spec.drift_frac)
owners = np.asarray(owners, dtype=np.int64)
if len(owners) == 0:
raise ValueError("make_queries: `owners` is empty")
raw_ptr = np.asarray(nodes.raw_ptr, dtype=np.int64)
lengths = np.diff(raw_ptr)
min_len_trav = window + advance
pool_trav = owners[lengths[owners] >= min_len_trav]
min_len_entry = advance + 1
pool_entry = owners[lengths[owners] >= min_len_entry]
entry_frac = float(entry_frac)
entry_static_frac = float(entry_static_frac)
if len(pool_entry) == 0 and (entry_frac > 0 or entry_static_frac > 0):
warnings.warn(
f"make_queries: no demo in `owners` has >= {min_len_entry} raw frames -- entry_frac "
f"{entry_frac} / entry_static_frac {entry_static_frac} forced to 0 for this call", stacklevel=2,
)
entry_frac = 0.0
entry_static_frac = 0.0
if len(pool_trav) == 0:
if entry_frac + entry_static_frac < 1.0:
raise ValueError(
f"make_queries: no demo in `owners` has >= {min_len_trav} raw frames (window={window} "
f"+ advance={advance}), and entry_frac={entry_frac}+entry_static_frac={entry_static_frac} "
"< 1 still needs traversal queries"
)
pool_trav = pool_entry # unused when entry_frac+entry_static_frac == 1.0, kept non-empty defensively
if drift_frac > 0 and window < DRIFT_MIN_HIST:
raise ValueError(
f"make_queries: drift_frac={drift_frac} needs window >= {DRIFT_MIN_HIST} for a window "
f"that can hold a clean-to-perturbed breakpoint, got window={window}"
)
if (entry_frac > 0 or entry_static_frac > 0 or drift_frac > 0) and entry_sigma_range is None:
floor = entry_perturbation_floor(nodes)
entry_sigma_range = (ENTRY_LO_MULT * floor["p50"], ENTRY_HI_MULT * floor["p99"])
dim = nodes.dim
q_raw = np.asarray(nodes.q_raw, dtype=np.float32)
corpus = _QueryCorpus(nodes=nodes, raw_ptr=raw_ptr, q_raw=q_raw, dim=dim, window=window)
q_hist = np.empty((n, window, dim), dtype=np.float32)
src_owner = np.empty(n, dtype=np.int32)
src_node = np.empty(n, dtype=np.int64)
tgt_node = np.empty(n, dtype=np.int64)
t_raw_now = np.empty(n, dtype=np.int64)
is_clean = np.empty(n, dtype=bool)
is_entry_static = np.zeros(n, dtype=bool)
is_drift = np.zeros(n, dtype=bool)
break_row = np.full(n, -1, dtype=np.int32)
perturb_radius = np.zeros(n, dtype=np.float32)
for i in range(n):
# A zero fraction must reproduce the RNG stream of the mix that predates its class, so every
# check short-circuits its own rng.rand() away rather than drawing and discarding it.
if entry_static_frac > 0:
is_static = rng.rand() < entry_static_frac
else:
is_static = False
is_entry = False if is_static else (entry_frac > 0 and rng.rand() < entry_frac)
drifts = False if (is_static or is_entry) else (drift_frac > 0 and rng.rand() < drift_frac)
if is_static:
row = _entry_static_row(corpus, rng, pool_entry, entry_sigma_range)
elif is_entry:
row = _entry_row(corpus, rng, pool_entry, advance, entry_sigma_range)
elif drifts:
row = _drift_row(corpus, rng, pool_trav, advance, entry_sigma_range)
else:
row = _traversal_row(corpus, rng, pool_trav, advance)
q_hist[i] = row.q_hist
src_owner[i] = row.src_owner
src_node[i] = row.src_node
tgt_node[i] = row.tgt_node
t_raw_now[i] = row.t_raw_now
is_clean[i] = row.is_clean
is_entry_static[i] = row.is_entry_static
is_drift[i] = row.is_drift
break_row[i] = row.break_row
perturb_radius[i] = row.perturb_radius
qdot_hist = finite_diff_vel(q_hist)
grip_hist = np.repeat(np.asarray(nodes.grip, dtype=np.float32)[src_node][:, None], window, axis=1)
w_hist = CleanlinessScorer(field).weights_batch(q_hist)
spacing = float(inter_demo_spacing) if inter_demo_spacing is not None else measure_inter_demo_spacing(nodes)
abstain_is_correct = off_manifold_batch(nodes, q_hist[:, -1, :]) < spacing
return {
"q_hist": q_hist, "qdot_hist": qdot_hist, "grip_hist": grip_hist, "w_hist": w_hist,
"src_owner": src_owner, "src_node": src_node, "tgt_node": tgt_node,
"t_raw_now": t_raw_now, "is_clean": is_clean,
"abstain_is_correct": abstain_is_correct, "is_entry_static": is_entry_static,
"is_drift": is_drift, "break_row": break_row, "perturb_radius": perturb_radius,
}
class NegativeMiner:
"""Hard-negative sampler bound to one fixed node table.
A class rather than a free function because the loss mines once per row, and the free-function
form re-ran np.asarray over the [V, D] node arrays on every one of those calls. The sampling
itself is unchanged, RNG draw for RNG draw.
Three categories, each falling back to uniform random negatives when it is empty or under quota
-- most importantly bucket (b), measured at only ~4.1% of frames:
(a) same t_idx as the positive, different strand;
(b) among the query's selfx_k nearest nodes in raw q, those whose phase differs from the
positive's by more than selfx_phase_gap;
(c) the query's own single nearest node, if it is not the positive.
"""
def __init__(
self, nodes: NodeTable, *, selfx_k: int = SELFX_K, selfx_phase_gap: float = SELFX_PHASE_GAP,
) -> None:
"""
Args:
nodes: The node table to mine from.
selfx_k: Size of the local kNN pool searched for bucket (b).
selfx_phase_gap: Phase gap that promotes a near-in-q neighbour into bucket (b).
"""
self.selfx_k = int(selfx_k)
self.selfx_phase_gap = float(selfx_phase_gap)
self.n_nodes = len(nodes)
self._owner = np.asarray(nodes.owner)
self._t_idx = np.asarray(nodes.t_idx)
self._phase = np.asarray(nodes.phase)
self._q = np.asarray(nodes.q, dtype=np.float64)
self._all_ids = np.arange(self.n_nodes, dtype=np.int64)
def mine(
self, query: np.ndarray, positive: int, rng: np.random.RandomState, n_neg: int,
) -> np.ndarray:
"""Mine hard negatives for one query.
Args:
query: [D] raw joint configuration, typically the window's most recent frame.
positive: Node id of the true continuation; never returned.
rng: Random generator, consumed in the documented bucket order (a, b, c) then the
uniform top-up, then one final shuffle.
n_neg: Cap on the number of negatives.
Returns:
[<=n_neg] i64 node ids.
"""
v = self.n_nodes
positive = int(positive)
if v <= 1 or n_neg <= 0:
return np.zeros(0, dtype=np.int64)
owner, t_idx, phase = self._owner, self._t_idx, self._phase
query = np.asarray(query, dtype=np.float64).reshape(-1)
pos_owner, pos_tidx, pos_phase = int(owner[positive]), int(t_idx[positive]), float(phase[positive])
bucket_a = np.flatnonzero((t_idx == pos_tidx) & (owner != pos_owner))
d = np.linalg.norm(self._q - query[None, :], axis=1)
# A full argsort, NOT argpartition: order[0] IS bucket (c), and bucket (b) is a slice of this
# order, so a different tie-break would change both the negatives drawn and the rng draws spent.
order = np.argsort(d)
knn = order[: min(self.selfx_k, v)]
bucket_b = knn[np.abs(phase[knn] - pos_phase) > self.selfx_phase_gap]
bucket_c = np.array([int(order[0])], dtype=np.int64)
quota = max(1, n_neg // 3)
picks: list[np.ndarray] = []
for bucket in (bucket_a, bucket_b, bucket_c):
bucket = np.asarray(bucket, dtype=np.int64)
bucket = bucket[bucket != positive]
if len(bucket) == 0:
continue
take = min(quota, len(bucket))
picks.append(rng.choice(bucket, size=take, replace=False))
picked = np.unique(np.concatenate(picks)) if picks else np.zeros(0, dtype=np.int64)
if len(picked) < n_neg:
pool = np.setdiff1d(self._all_ids, np.append(picked, positive))
need = n_neg - len(picked)
if len(pool) > 0:
extra = rng.choice(pool, size=min(need, len(pool)), replace=False)
picked = np.concatenate([picked, extra])
rng.shuffle(picked)
return picked[:n_neg].astype(np.int64)
def mine_negatives(
nodes: NodeTable, query: np.ndarray, positive: int, rng: np.random.RandomState, n_neg: int,
) -> np.ndarray:
"""One-shot NegativeMiner.mine for a caller that has no miner to hand.
Args:
nodes: Node table to mine from.
query: [D] raw joint configuration.
positive: Node id of the true continuation.
rng: Random generator.
n_neg: Cap on the number of negatives.
Returns:
[<=n_neg] i64 node ids.
"""
return NegativeMiner(nodes).mine(query, positive, rng, n_neg)
class SoftTargetBuilder:
"""Multi-positive node-identity targets for one fixed node table.
Owns the graph arrays (smooth is called once per training row, and re-deriving [V, D] q per row
is pure waste), the ENTRY_STATIC task_id -> pool cache, and the log-once empty-pool latch. Both
target formulas are unchanged; this class only decides where their inputs live.
Attributes:
pool: task_id -> node ids with phase <= entry_static_band, the ENTRY_STATIC target pool.
"""
def __init__(
self, nodes: NodeTable, *, phase_band: float = schema.WHERE_PHASE_BAND,
move_temp: float = schema.WHERE_MOVE_TEMP, true_bonus: float = schema.WHERE_TRUE_BONUS,
entry_static_band: float = ENTRY_STATIC_BAND,
) -> None:
"""
Args:
nodes: Node table the targets index into.
phase_band: Half-width of the accepted phase band around the positive.
move_temp: Temperature of the inverse-movement-cost weighting.
true_bonus: Multiplier applied to the true continuation before renormalising.
entry_static_band: Phase ceiling defining the ENTRY_STATIC pool.
"""
self.phase_band = float(phase_band)
self.move_temp = float(move_temp)
self.true_bonus = float(true_bonus)
self.entry_static_band = float(entry_static_band)
self._task_id = np.asarray(nodes.task_id)
self._phase = np.asarray(nodes.phase)
self._q = np.asarray(nodes.q, dtype=np.float64)
self.pool = _entry_static_pools(self._task_id, self._phase, self.entry_static_band)
self._warned = False
def smooth(
self, positive: int, q_now: np.ndarray | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""The movement-weighted multi-positive target. See label_smooth_targets.
Args:
positive: Node id of the true continuation.
q_now: [D] current raw joint configuration, or None for the phase-distance proxy.
Returns:
(candidates [K] i64, weights [K] f32 summing to 1).
"""
task_id, phase = self._task_id, self._phase
positive = int(positive)
pos_task = int(task_id[positive])
pos_phase = float(phase[positive])
cand = np.flatnonzero((task_id == pos_task) & (np.abs(phase - pos_phase) <= self.phase_band))
if positive not in cand:
cand = np.append(cand, np.int64(positive))
cand = cand.astype(np.int64)
if q_now is not None:
q_now = np.asarray(q_now, dtype=np.float64).reshape(-1)
cost = np.linalg.norm(self._q[cand] - q_now[None, :], axis=1)
else:
cost = np.abs(phase[cand].astype(np.float64) - pos_phase)
logw = -cost / max(self.move_temp, 1e-8)
logw = logw - logw.max() # numerical stability only; exp() below is unaffected
w = np.exp(logw)
w[cand == positive] *= self.true_bonus
return cand, (w / w.sum()).astype(np.float32)
def entry_static(
self, positive: int, q_now: np.ndarray | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""The ENTRY_STATIC class's target: uniform over the positive's task pool.
The deployed hand rule this class exists to teach IS "uniform over task lane intersect
phase<=band", so the label reproduces that rule exactly. An empty pool is rare and worth
knowing about, so it warns -- once per builder, not once per row.
Args:
positive: Node id of the true continuation.
q_now: [D] current raw joint configuration, used only by the fallback.
Returns:
(candidates [K] i64, weights [K] f32 summing to 1).
"""
task_id = int(self._task_id[int(positive)])
pool = self.pool.get(task_id)
if pool is not None and len(pool) > 0:
return pool, np.full(len(pool), 1.0 / len(pool), dtype=np.float32)
if not self._warned:
self._warned = True
warnings.warn(
f"SoftTargetBuilder: empty ENTRY_STATIC pool for task_id={task_id} (no node at "
f"phase<=ENTRY_STATIC_BAND={self.entry_static_band}) -- falling back to the smoothed "
"target for this row (further occurrences not logged)", stacklevel=2,
)
return self.smooth(positive, q_now)
def _entry_static_pools(
task_id: np.ndarray, phase: np.ndarray, band: float
) -> dict[int, np.ndarray]:
"""task_id -> node ids with phase <= band.
Args:
task_id: [V] per-node task ids.
phase: [V] per-node phase.
band: Phase ceiling.
Returns:
One i64 id array per distinct task id.
"""
in_band = phase <= float(band)
return {int(t): np.flatnonzero(in_band & (task_id == t)).astype(np.int64) for t in np.unique(task_id)}
def label_smooth_targets(
nodes: NodeTable, positive: int, q_now: np.ndarray | None = None, *,
phase_band: float = schema.WHERE_PHASE_BAND, move_temp: float = schema.WHERE_MOVE_TEMP,
true_bonus: float = schema.WHERE_TRUE_BONUS,
) -> tuple[np.ndarray, np.ndarray]:
"""Soft multi-positive target: every node sharing positive's task_id within +/- phase_band.
Weighted by inverse movement cost from q_now: w_v = exp(-||nodes.q[v] - q_now|| / move_temp),
normalised to sum to 1. q_now=None falls back to a phase-distance proxy so the target still
decays smoothly within the band rather than collapsing to uniform. The true continuation is
always the single highest-weighted candidate (cost 0, then multiplied by true_bonus), and is
always included, so the candidate array is never empty.
One-shot form of SoftTargetBuilder.smooth; training goes through a long-lived builder.
Args:
nodes: Node table the targets index into.
positive: Node id of the true continuation.
q_now: [D] current raw joint configuration, or None.
phase_band: Half-width of the accepted phase band.
move_temp: Temperature of the inverse-movement-cost weighting.
true_bonus: Multiplier applied to the true continuation.
Returns:
(candidates [K] i64, weights [K] f32 summing to 1).
"""
return SoftTargetBuilder(
nodes, phase_band=phase_band, move_temp=move_temp, true_bonus=true_bonus
).smooth(positive, q_now)
def build_entry_static_pool_cache(nodes: NodeTable, band: float = ENTRY_STATIC_BAND) -> dict[int, np.ndarray]:
"""task_id -> node ids with phase <= band -- the ENTRY_STATIC class's soft-target pool.
Training reads the same mapping off SoftTargetBuilder.pool, built once per run.
Args:
nodes: Node table to bucket.
band: Phase ceiling; ENTRY_STATIC_BAND matches the deployed GR_ENTRY_BAND.
Returns:
One i64 id array per distinct task id.
"""
return _entry_static_pools(np.asarray(nodes.task_id), np.asarray(nodes.phase), band)

Xet Storage Details

Size:
35.8 kB
·
Xet hash:
c881126b58e17790e8a2bab285ba8a37a907f3049ca1f305614979fc1e058735

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.