mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-26 03:06:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f60babc946 | |||
| db2972fb6c |
@@ -346,116 +346,77 @@ def _strip_recipe_keys(m: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HighLevelSubtaskFwd(InferenceStep):
|
class HighLevelSubtaskFwd(InferenceStep):
|
||||||
"""At ~1 Hz, ask the policy for the next subtask.
|
"""Deterministic plan walker — current subtask is the head of the plan.
|
||||||
|
|
||||||
Mirrors the ``high_level_subtask`` recipe layout exactly:
|
Rationale
|
||||||
|
---------
|
||||||
|
The training-time ``plan_generation`` recipe summarises subtasks as
|
||||||
|
a numbered list of still-todo items. The ``high_level_subtask``
|
||||||
|
recipe supervises subtask text auto-regressively. Empirically the
|
||||||
|
LM head's AR generation collapses on small datasets and produces
|
||||||
|
repetitive / off-distribution subtasks
|
||||||
|
("…extends and retracts and retracts…"), which corrupts the
|
||||||
|
action expert's conditioning.
|
||||||
|
|
||||||
user: "${task}\\nPlan: ${plan}\\nMemory: ${memory}"
|
Instead of trusting the LM head's free generation, walk the plan:
|
||||||
user: "Current subtask: ${subtask}" (if subtask present)
|
|
||||||
↓ generate ↓
|
* The plan is a string ``"1. <subtask>\\n2. <subtask>\\n…"``.
|
||||||
assistant: <next subtask>
|
* The current subtask is the line at ``state["plan_index"]``.
|
||||||
|
* After each chunk dispatch (action queue empties), advance the
|
||||||
|
index by one.
|
||||||
|
|
||||||
|
This is interpretable, can't repetition-collapse, and stays in
|
||||||
|
lockstep with the deterministic plan that ``plan_generation``
|
||||||
|
emits.
|
||||||
|
|
||||||
|
The LM head is still trained and still used for plan generation
|
||||||
|
(one-shot at episode start), VQA, and interjection replanning —
|
||||||
|
just not for the always-on per-chunk subtask loop.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
policy: Any = None
|
policy: Any = None
|
||||||
observation_provider: Any = None
|
observation_provider: Any = None # kept for signature compatibility
|
||||||
"""Same shape as ``LowLevelForward.observation_provider``. When
|
|
||||||
set, the resulting observation is merged into ``select_message``'s
|
|
||||||
batch so text generation runs against real video + state."""
|
|
||||||
|
|
||||||
trigger: Trigger = field(default_factory=lambda: HzTrigger(hz=1.0))
|
trigger: Trigger = field(default_factory=lambda: HzTrigger(hz=1.0))
|
||||||
|
|
||||||
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
if self.policy is None or not state.get("task"):
|
if not state.get("task"):
|
||||||
return None
|
return None
|
||||||
# Gate to chunk boundaries: only generate a fresh subtask when
|
# Only advance at chunk boundaries (action queue empty) — same
|
||||||
# the action queue is empty (i.e. right before LowLevelForward
|
# gating as the original AR path, so subtask transitions stay
|
||||||
# refreshes the chunk). ``select_message`` takes ~2 s on MPS,
|
# aligned with the dispatch cycle.
|
||||||
# and running it every loop iteration starves DispatchAction
|
|
||||||
# at ctrl_hz=30 — the queue drains at ~0.4 actions/sec instead
|
|
||||||
# of 30/sec and the robot barely moves. Tying it to the same
|
|
||||||
# "queue empty" condition as the chunk refresh produces a
|
|
||||||
# clean sense → think → act cycle.
|
|
||||||
queue = state.get("action_queue") or []
|
queue = state.get("action_queue") or []
|
||||||
if len(queue) > 0:
|
if len(queue) > 0:
|
||||||
return None
|
return None
|
||||||
ctx = _msgs_for_subtask(state)
|
|
||||||
observation = _maybe_observation(self.observation_provider)
|
plan_lines = _parse_plan_lines(state.get("current_plan") or "")
|
||||||
# Default: greedy argmax, no min_new_tokens, no special-token
|
if not plan_lines:
|
||||||
# suppression — matches training. Operator can override via
|
# No plan available yet (e.g. plan_generation hasn't fired
|
||||||
# ``--text_min_new_tokens=N --text_temperature=T --text_top_p=P``
|
# on this episode). Fall back to the task string so the
|
||||||
# on the CLI; useful for under-trained checkpoints whose LM
|
# action expert at least has *something* to condition on.
|
||||||
# head still favours EOS at position 0 (pre-trained chat
|
fallback = state.get("task") or ""
|
||||||
# backbone's short-turn prior hasn't been fully overridden
|
if fallback and state.get("current_subtask") != fallback:
|
||||||
# by the fine-tuning supervision yet).
|
set_if_changed(state, "current_subtask", fallback, label="subtask")
|
||||||
msg = _generate_with_policy(
|
|
||||||
self.policy,
|
|
||||||
ctx,
|
|
||||||
observation=observation,
|
|
||||||
state=state,
|
|
||||||
label="subtask gen",
|
|
||||||
min_new_tokens=int(state.get("text_gen_min_new_tokens") or 0),
|
|
||||||
temperature=float(state.get("text_gen_temperature") or 0.0),
|
|
||||||
top_p=float(state.get("text_gen_top_p") or 1.0),
|
|
||||||
)
|
|
||||||
# Diagnostics: surface what the model is *actually* producing
|
|
||||||
# at chunk boundaries, even when the output gets rejected or
|
|
||||||
# repeats. Memorisation collapse looks like "same accepted
|
|
||||||
# subtask N times in a row" or "gibberish_count rising while
|
|
||||||
# current_subtask is stuck". The state panel renders these.
|
|
||||||
state["last_subtask_raw"] = msg or ""
|
|
||||||
# Persistent empty completion is its own failure mode (model
|
|
||||||
# immediately EOS-es from the chat-template generation
|
|
||||||
# prompt) — surface it once every N occurrences so the
|
|
||||||
# operator can distinguish "generation failing silently"
|
|
||||||
# from "generating fine but filter rejecting".
|
|
||||||
if not msg:
|
|
||||||
empties = state.get("subtask_empty_count", 0) + 1
|
|
||||||
state["subtask_empty_count"] = empties
|
|
||||||
if empties == 1 or empties % 5 == 0:
|
|
||||||
debug = getattr(self.policy, "_last_select_message_debug", "") or ""
|
|
||||||
if debug:
|
|
||||||
push_log(
|
|
||||||
state,
|
|
||||||
f" [info] subtask gen empty (×{empties}); {debug}",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
push_log(
|
|
||||||
state,
|
|
||||||
f" [info] subtask gen returned empty (×{empties}) — "
|
|
||||||
"no tokens generated (head EOS-ing before any "
|
|
||||||
"non-special token).",
|
|
||||||
)
|
|
||||||
if msg and _looks_like_gibberish(msg):
|
|
||||||
# Bump a counter so the operator can see the model is
|
|
||||||
# struggling without spamming the log every tick. A first
|
|
||||||
# rejection still logs once so the failure is visible.
|
|
||||||
count = state.get("subtask_gibberish_count", 0) + 1
|
|
||||||
state["subtask_gibberish_count"] = count
|
|
||||||
if count == 1 or count % 30 == 0:
|
|
||||||
push_log(
|
|
||||||
state,
|
|
||||||
f" [info] subtask gen rejected (gibberish ×{count}): {msg[:60]!r}",
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
if msg:
|
|
||||||
changed = set_if_changed(state, "current_subtask", msg, label="subtask")
|
# ``plan_index`` advances by 1 each chunk boundary. Tracked via
|
||||||
if changed:
|
# ``actions_dispatched`` (set by ``DispatchAction``): we advance
|
||||||
# Subtask change is a downstream trigger.
|
# only when at least one new action was dispatched since the
|
||||||
state.setdefault("events_this_tick", []).append("subtask_change")
|
# previous walker tick. Clamps to the last line so the robot
|
||||||
state["subtask_repeat_count"] = 0
|
# keeps executing the final subtask instead of going silent.
|
||||||
else:
|
plan_index = int(state.get("plan_index", 0) or 0)
|
||||||
# Same accepted string regenerated — memorisation tell.
|
dispatched = int(state.get("actions_dispatched", 0) or 0)
|
||||||
# Once this counter climbs past a few, you're seeing
|
last_dispatched = state.get("_subtask_walker_last_dispatched")
|
||||||
# the model unable to move past the current subtask
|
if last_dispatched is not None and dispatched > int(last_dispatched):
|
||||||
# despite the chunk having drained (visual scene may
|
plan_index = min(plan_index + 1, len(plan_lines) - 1)
|
||||||
# have changed but the LM is replaying training
|
state["plan_index"] = plan_index
|
||||||
# tokens).
|
state["_subtask_walker_last_dispatched"] = dispatched
|
||||||
state["subtask_repeat_count"] = (
|
|
||||||
state.get("subtask_repeat_count", 0) + 1
|
new_subtask = plan_lines[plan_index]
|
||||||
)
|
changed = set_if_changed(state, "current_subtask", new_subtask, label="subtask")
|
||||||
# Silently skip empty completions — common when the model
|
if changed:
|
||||||
# warms up or generates only EOS; logging it every tick at
|
state.setdefault("events_this_tick", []).append("subtask_change")
|
||||||
# ctrl_hz is just noise.
|
# Surface diagnostics in the panel format the runtime expects.
|
||||||
|
state["last_subtask_raw"] = new_subtask
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -488,6 +449,11 @@ class MemoryUpdateFwd(InferenceStep):
|
|||||||
observation=observation,
|
observation=observation,
|
||||||
state=state,
|
state=state,
|
||||||
label="memory gen",
|
label="memory gen",
|
||||||
|
min_new_tokens=int(state.get("text_gen_min_new_tokens") or 0),
|
||||||
|
temperature=float(state.get("text_gen_temperature") or 0.0),
|
||||||
|
top_p=float(state.get("text_gen_top_p") or 1.0),
|
||||||
|
repetition_penalty=float(state.get("text_gen_repetition_penalty") or 1.0),
|
||||||
|
no_repeat_ngram_size=int(state.get("text_gen_no_repeat_ngram_size") or 0),
|
||||||
)
|
)
|
||||||
state["last_memory_raw"] = new_memory or ""
|
state["last_memory_raw"] = new_memory or ""
|
||||||
if new_memory and _looks_like_gibberish(new_memory):
|
if new_memory and _looks_like_gibberish(new_memory):
|
||||||
@@ -531,6 +497,11 @@ class UserInterjectionFwd(InferenceStep):
|
|||||||
observation=observation,
|
observation=observation,
|
||||||
state=state,
|
state=state,
|
||||||
label="plan/say gen",
|
label="plan/say gen",
|
||||||
|
min_new_tokens=int(state.get("text_gen_min_new_tokens") or 0),
|
||||||
|
temperature=float(state.get("text_gen_temperature") or 0.0),
|
||||||
|
top_p=float(state.get("text_gen_top_p") or 1.0),
|
||||||
|
repetition_penalty=float(state.get("text_gen_repetition_penalty") or 1.0),
|
||||||
|
no_repeat_ngram_size=int(state.get("text_gen_no_repeat_ngram_size") or 0),
|
||||||
)
|
)
|
||||||
if not out:
|
if not out:
|
||||||
# Don't log every empty completion — happens repeatedly on
|
# Don't log every empty completion — happens repeatedly on
|
||||||
@@ -600,6 +571,11 @@ class AskVQAFwd(InferenceStep):
|
|||||||
observation=observation,
|
observation=observation,
|
||||||
state=state,
|
state=state,
|
||||||
label="vqa gen",
|
label="vqa gen",
|
||||||
|
min_new_tokens=int(state.get("text_gen_min_new_tokens") or 0),
|
||||||
|
temperature=float(state.get("text_gen_temperature") or 0.0),
|
||||||
|
top_p=float(state.get("text_gen_top_p") or 1.0),
|
||||||
|
repetition_penalty=float(state.get("text_gen_repetition_penalty") or 1.0),
|
||||||
|
no_repeat_ngram_size=int(state.get("text_gen_no_repeat_ngram_size") or 0),
|
||||||
)
|
)
|
||||||
# VQA answers are intentionally JSON-like during training, so
|
# VQA answers are intentionally JSON-like during training, so
|
||||||
# ``_looks_like_gibberish`` would false-positive on them. Keep
|
# ``_looks_like_gibberish`` would false-positive on them. Keep
|
||||||
@@ -646,6 +622,28 @@ class DispatchToolCalls(InferenceStep):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_PLAN_LINE_RE = re.compile(r"^\s*\d+[.)]\s*(.+?)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_plan_lines(plan_text: str) -> list[str]:
|
||||||
|
"""Split a numbered-list plan string into its individual subtask lines.
|
||||||
|
|
||||||
|
Accepts both ``"1. foo"`` and ``"1) foo"`` formatting. Falls back to
|
||||||
|
splitting on newlines for non-numbered plans. Empty lines are
|
||||||
|
dropped. Returns the bare subtask text (number prefix stripped).
|
||||||
|
"""
|
||||||
|
if not plan_text:
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in plan_text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = _PLAN_LINE_RE.match(line)
|
||||||
|
out.append(m.group(1).strip() if m else line)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_gibberish(text: str) -> bool:
|
def _looks_like_gibberish(text: str) -> bool:
|
||||||
"""Heuristically detect generation that's clearly off the rails.
|
"""Heuristically detect generation that's clearly off the rails.
|
||||||
|
|
||||||
@@ -829,6 +827,8 @@ def _generate_with_policy(
|
|||||||
min_new_tokens: int = 0,
|
min_new_tokens: int = 0,
|
||||||
temperature: float = 0.0,
|
temperature: float = 0.0,
|
||||||
top_p: float = 1.0,
|
top_p: float = 1.0,
|
||||||
|
repetition_penalty: float = 1.0,
|
||||||
|
no_repeat_ngram_size: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Drive ``policy.select_message`` with a chat batch (and optional obs).
|
"""Drive ``policy.select_message`` with a chat batch (and optional obs).
|
||||||
|
|
||||||
@@ -869,6 +869,8 @@ def _generate_with_policy(
|
|||||||
min_new_tokens=min_new_tokens,
|
min_new_tokens=min_new_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
|
repetition_penalty=repetition_penalty,
|
||||||
|
no_repeat_ngram_size=no_repeat_ngram_size,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("%s failed: %s", label, exc, exc_info=logger.isEnabledFor(logging.DEBUG))
|
logger.warning("%s failed: %s", label, exc, exc_info=logger.isEnabledFor(logging.DEBUG))
|
||||||
|
|||||||
@@ -85,6 +85,25 @@ def _locate_lang_range(prefix_att_masks: Tensor, num_lang: int) -> tuple[int, in
|
|||||||
return lang_start, lang_end
|
return lang_start, lang_end
|
||||||
|
|
||||||
|
|
||||||
|
def _ngram_banned_ids(generated: list[int], n: int) -> set[int]:
|
||||||
|
"""Standard ``no_repeat_ngram_size`` ban list.
|
||||||
|
|
||||||
|
For every ``(n-1)``-gram suffix in ``generated`` that already
|
||||||
|
appeared earlier in ``generated``, the next-token id that
|
||||||
|
*completed* it is banned — emitting it would produce a repeated
|
||||||
|
``n``-gram. Matches HF's ``_get_ngrams`` semantics.
|
||||||
|
"""
|
||||||
|
if n <= 0 or len(generated) < n:
|
||||||
|
return set()
|
||||||
|
cur_prefix = tuple(generated[-(n - 1):]) if n > 1 else ()
|
||||||
|
banned: set[int] = set()
|
||||||
|
for i in range(len(generated) - n + 1):
|
||||||
|
ngram = tuple(generated[i : i + n])
|
||||||
|
if ngram[:-1] == cur_prefix:
|
||||||
|
banned.add(ngram[-1])
|
||||||
|
return banned
|
||||||
|
|
||||||
|
|
||||||
def _shifted_ce(logits: Tensor, text_labels: Tensor) -> Tensor:
|
def _shifted_ce(logits: Tensor, text_labels: Tensor) -> Tensor:
|
||||||
"""Next-token CE: hidden at t predicts label at t+1, ignore_index=-100."""
|
"""Next-token CE: hidden at t predicts label at t+1, ignore_index=-100."""
|
||||||
num_lang = logits.shape[1]
|
num_lang = logits.shape[1]
|
||||||
@@ -434,6 +453,8 @@ class SmolVLA2Policy(SmolVLAPolicy):
|
|||||||
eos_token_id: int | None = None,
|
eos_token_id: int | None = None,
|
||||||
temperature: float = 0.0,
|
temperature: float = 0.0,
|
||||||
top_p: float = 1.0,
|
top_p: float = 1.0,
|
||||||
|
repetition_penalty: float = 1.0,
|
||||||
|
no_repeat_ngram_size: int = 0,
|
||||||
tokenizer: Any = None,
|
tokenizer: Any = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate text continuation from the chat-templated prompt.
|
"""Generate text continuation from the chat-templated prompt.
|
||||||
@@ -566,6 +587,28 @@ class SmolVLA2Policy(SmolVLAPolicy):
|
|||||||
if special_ids_set and len(generated) < min_new_tokens:
|
if special_ids_set and len(generated) < min_new_tokens:
|
||||||
for sid in special_ids_set:
|
for sid in special_ids_set:
|
||||||
logits_step[..., sid] = float("-inf")
|
logits_step[..., sid] = float("-inf")
|
||||||
|
# Repetition controls — break the "extends and retracts and
|
||||||
|
# retracts" loops that undertrained LM heads fall into under
|
||||||
|
# greedy decoding. Standard HF generation behaviour:
|
||||||
|
# ``repetition_penalty`` divides positive logits / multiplies
|
||||||
|
# negative logits for already-emitted token ids;
|
||||||
|
# ``no_repeat_ngram_size`` hard-bans any token that would
|
||||||
|
# complete an n-gram already seen in the generated suffix.
|
||||||
|
if repetition_penalty != 1.0 and generated:
|
||||||
|
seen_ids = list(set(generated))
|
||||||
|
logit_row = logits_step[0]
|
||||||
|
vals = logit_row[seen_ids]
|
||||||
|
vals = torch.where(
|
||||||
|
vals > 0,
|
||||||
|
vals / repetition_penalty,
|
||||||
|
vals * repetition_penalty,
|
||||||
|
)
|
||||||
|
logit_row[seen_ids] = vals
|
||||||
|
if no_repeat_ngram_size > 0 and len(generated) >= no_repeat_ngram_size:
|
||||||
|
banned = _ngram_banned_ids(generated, no_repeat_ngram_size)
|
||||||
|
if banned:
|
||||||
|
for bad in banned:
|
||||||
|
logits_step[..., bad] = float("-inf")
|
||||||
next_ids = self._sample_next_token(logits_step, temperature, top_p)
|
next_ids = self._sample_next_token(logits_step, temperature, top_p)
|
||||||
tok_id = int(next_ids[0].item())
|
tok_id = int(next_ids[0].item())
|
||||||
generated.append(tok_id)
|
generated.append(tok_id)
|
||||||
|
|||||||
@@ -288,6 +288,30 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
default=1.0,
|
default=1.0,
|
||||||
help="Nucleus filtering for high-level text gen.",
|
help="Nucleus filtering for high-level text gen.",
|
||||||
)
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--text_repetition_penalty",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help=(
|
||||||
|
"Per-token repetition penalty (HF-style: divides positive "
|
||||||
|
"logits / multiplies negative logits for already-emitted "
|
||||||
|
"ids). ``1.0`` = off. Try ``1.2``-``1.5`` on under-trained "
|
||||||
|
"checkpoints that fall into n-gram loops like "
|
||||||
|
"``\"extends and retracts and retracts and retracts\"``."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--text_no_repeat_ngram_size",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help=(
|
||||||
|
"Hard-ban any token that would complete an n-gram already "
|
||||||
|
"present in the generated suffix. ``0`` = off. ``3`` kills "
|
||||||
|
"most LM-head repetition collapses without distorting the "
|
||||||
|
"next-token distribution; ``2`` is more aggressive but can "
|
||||||
|
"block legit ``\"the X\"`` patterns."
|
||||||
|
),
|
||||||
|
)
|
||||||
p.add_argument("-v", "--verbose", action="store_true", help="Enable DEBUG logging.")
|
p.add_argument("-v", "--verbose", action="store_true", help="Enable DEBUG logging.")
|
||||||
return p.parse_args(argv)
|
return p.parse_args(argv)
|
||||||
|
|
||||||
@@ -1333,6 +1357,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
runtime.state["text_gen_min_new_tokens"] = int(getattr(args, "text_min_new_tokens", 0) or 0)
|
runtime.state["text_gen_min_new_tokens"] = int(getattr(args, "text_min_new_tokens", 0) or 0)
|
||||||
runtime.state["text_gen_temperature"] = float(getattr(args, "text_temperature", 0.0) or 0.0)
|
runtime.state["text_gen_temperature"] = float(getattr(args, "text_temperature", 0.0) or 0.0)
|
||||||
runtime.state["text_gen_top_p"] = float(getattr(args, "text_top_p", 1.0) or 1.0)
|
runtime.state["text_gen_top_p"] = float(getattr(args, "text_top_p", 1.0) or 1.0)
|
||||||
|
runtime.state["text_gen_repetition_penalty"] = float(
|
||||||
|
getattr(args, "text_repetition_penalty", 1.0) or 1.0
|
||||||
|
)
|
||||||
|
runtime.state["text_gen_no_repeat_ngram_size"] = int(
|
||||||
|
getattr(args, "text_no_repeat_ngram_size", 0) or 0
|
||||||
|
)
|
||||||
if args.task:
|
if args.task:
|
||||||
runtime.set_task(args.task)
|
runtime.set_task(args.task)
|
||||||
# Seed plan/memory/subtask so the first prompt the runtime builds
|
# Seed plan/memory/subtask so the first prompt the runtime builds
|
||||||
|
|||||||
Reference in New Issue
Block a user