CoolFace
Apppublic

DGXAI/driftcall

sourceHugging Faceapache-2.0updated 5mo agoView on Hugging Face
0likes
step_14_custom_trainer.py527 linesDownload Raw Back to cells
1"""Custom trainer + dataset adapter (docs/modules/training.md §2.2, §3.2.3).2 3Two public types:4 5- :class:`EpisodeDatasetAdapter` — stateless iterable feeding6  ``GRPOTrainer.train_dataset``. Each ``__iter__`` tick yields7  ``{"prompt": str, "_meta": {...}}`` where ``_meta`` carries the8  ``GoalSpec``, the monotonically-derived ``episode_seed``, the curriculum9  ``stage``, and the ``language_weights``. One call to10  ``task_generator.generate`` per step; one call to11  ``tokenizer.apply_chat_template(messages, tokenize=False,12  add_generation_prompt=True)`` to render the prompt.13 14- :class:`DriftCallGRPOTrainer` — ``GRPOTrainer`` subclass whose15  ``_generate_and_score_completions`` override runs G multi-turn episodes16  via a caller-provided ``RolloutGroupFn`` and plumbs the resulting17  frozen ``Episode`` tuple into ``reward_fn`` (step_13) before handing the18  G reward scalars + padded completions back to the inherited GRPO19  advantage / KL / optimizer step path. **The inherited code path is20  untouched** (training.md §3.2.3).21 22``trl`` and ``torch`` are imported lazily. Pure-Python fallbacks for23``_generate_and_score_completions`` are provided so the class shape24can be verified on CPU-only CI.25"""26 27from __future__ import annotations28 29import math30from dataclasses import dataclass31from typing import TYPE_CHECKING, Any, Literal, Protocol, cast32 33if TYPE_CHECKING:  # pragma: no cover - typing only34    from collections.abc import Callable, Iterator35 36from cells.step_13_grpo_config import BETA_KL37 38PINNED_SYSTEM_PROMPT: str = (39    "You are a concierge assistant. Use the provided tools. "40    "Respond in the caller's language. Submit with calibrated confidence."41)42 43LanguageCode = Literal["hi", "ta", "kn", "en", "hinglish"]44 45 46class EpisodeSampler(Protocol):47    """Draws a ``GoalSpec`` for one prompt slot (training.md §2.2)."""48 49    def __call__(self, step: int) -> Any: ...50 51 52class EnvFactory(Protocol):53    """Returns a fresh ``DriftCallEnv`` per rollout (training.md §3.2)."""54 55    def __call__(self) -> Any: ...56 57 58class RolloutGroupFn(Protocol):59    """Runs G multi-turn rollouts sharing one goal.60 61    Returns a tuple ``(episodes, completions)`` of length G each.62    """63 64    def __call__(65        self,66        *,67        model: Any,68        tokenizer: Any,69        goal: Any,70        episode_seed: int,71        num_generations: int,72        env_factory: EnvFactory,73    ) -> tuple[tuple[Any, ...], tuple[str, ...]]: ...74 75 76@dataclass(frozen=True)77class AdapterRecord:78    """Frozen view of one :class:`EpisodeDatasetAdapter` yield.79 80    Tests consume this view rather than dict-typing ``_meta`` inline.81    """82 83    prompt: str84    goal: Any85    episode_seed: int86    stage: Literal[1, 2, 3]87    language_weights: dict[LanguageCode, float]88 89 90def render_initial_prompt(tokenizer: Any, goal: Any) -> str:91    """Render the turn-0 chat template (training.md §3.2.1).92 93    Messages: pinned system prompt + ``goal.seed_utterance`` as the user94    turn. ``add_generation_prompt=True`` tells Gemma to emit an assistant95    turn. Tool schemas live in later turns so only these two messages96    appear at ``step == 0``.97    """98    seed_utterance = getattr(goal, "seed_utterance", "")99    messages: list[dict[str, str]] = [100        {"role": "system", "content": PINNED_SYSTEM_PROMPT},101        {"role": "user", "content": seed_utterance},102    ]103    result = tokenizer.apply_chat_template(104        messages,105        tokenize=False,106        add_generation_prompt=True,107    )108    return str(result)109 110 111class EpisodeDatasetAdapter:112    """Stateless streaming dataset (training.md §2.2).113 114    Constructor signature matches training.md §2.2: a ``task_gen`` callable115    accepting ``(seed, stage, language_weights)``, an ``env_factory``116    producing fresh envs, the curriculum ``stage``, a ``stage_base_seed``117    used to derive per-step ``episode_seed``, the per-language sampling118    ``language_weights``, and the ``tokenizer`` used to render prompts.119 120    Iteration is infinite — exactly one record per GRPO training step.121    Step counter is local to ``__iter__`` so resume simply restarts from122    whatever step TRL's ``resume_from_checkpoint`` restores.123    """124 125    def __init__(126        self,127        *,128        task_gen: Callable[..., Any],129        env_factory: EnvFactory,130        stage: Literal[1, 2, 3],131        stage_base_seed: int,132        language_weights: dict[LanguageCode, float],133        tokenizer: Any,134    ) -> None:135        self.task_gen = task_gen136        self.env_factory = env_factory137        self.stage: Literal[1, 2, 3] = stage138        self.stage_base_seed = stage_base_seed139        self.language_weights = dict(language_weights)140        self.tokenizer = tokenizer141 142    def _build_record(self, step: int) -> dict[str, Any]:143        episode_seed = self.stage_base_seed + step144        goal = self.task_gen(145            seed=episode_seed,146            stage=self.stage,147            language_weights=self.language_weights,148        )149        prompt = render_initial_prompt(self.tokenizer, goal)150        return {151            "prompt": prompt,152            "_meta": {153                "goal": goal,154                "episode_seed": episode_seed,155                "stage": self.stage,156                "language_weights": dict(self.language_weights),157            },158        }159 160    def __iter__(self) -> Iterator[dict[str, Any]]:161        step = 0162        while True:163            yield self._build_record(step)164            step += 1165 166    def __len__(self) -> int:167        """Length sentinel for TRL 0.24+ ``RepeatSampler``.168 169        The dataset is logically infinite (one record per GRPO step), but170        TRL 0.24's ``RepeatSampler`` calls ``len(data_source)`` to size the171        sampler. Returning a large finite number lets training proceed; the172        actual step count is bounded by ``GRPOConfig.max_steps``.173        """174        return 1_000_000175 176    def __getitem__(self, idx: int) -> dict[str, Any]:177        """Map-style indexing for TRL 0.24+ DataLoader.178 179        TRL 0.24 treats the train_dataset as a Map-style dataset and looks180        records up by integer index. We honour the contract by deriving the181        record purely from ``idx`` — the adapter is stateless so any index182        produces a deterministic ``(prompt, _meta)`` pair for that step.183        """184        return self._build_record(int(idx))185 186    def peek(self, step: int) -> AdapterRecord:187        """Materialize the record at ``step`` without advancing iteration.188 189        Used by tests (§1.2 U14–U18) to assert record shape at arbitrary190        steps without consuming a generator.191        """192        rec = self._build_record(step)193        meta = rec["_meta"]194        return AdapterRecord(195            prompt=rec["prompt"],196            goal=meta["goal"],197            episode_seed=meta["episode_seed"],198            stage=meta["stage"],199            language_weights=meta["language_weights"],200        )201 202 203def _import_grpo_trainer() -> type[Any]:204    """Lazy import of ``trl.GRPOTrainer``; isolated for mocking in tests."""205    from trl import GRPOTrainer206 207    return cast("type[Any]", GRPOTrainer)208 209 210def _make_driftcall_init(211    base_cls: type[Any],212) -> Callable[..., None]:213    """Build an ``__init__`` bound to ``base_cls``; avoids super() recursion214    when the returned class is itself further subclassed.215 216    DriftCall-specific kwargs added on top of ``GRPOTrainer.__init__``:217 218    - ``rollout_group_fn``, ``env_factory``, ``reward_fn_driftcall`` — the219      multi-turn rollout override surface (see class docstring).220    - ``enable_adaptive_kl`` (default ``True``) — auto-attach an221      :class:`AdaptiveKLCallback` so β retargets to the measured KL each222      logging tick (training.md §3.3.1). Set ``False`` to disable.223    - ``adaptive_kl_target`` — override the default ``target_kl=BETA_KL``.224    - ``adaptive_kl_kp`` — override the proportional gain.225    - ``adaptive_kl_beta_min`` / ``adaptive_kl_beta_max`` — override clamp226      bounds.227    """228 229    def _init(230        self: Any,231        *args: Any,232        rollout_group_fn: RolloutGroupFn,233        env_factory: EnvFactory,234        reward_fn_driftcall: Callable[..., list[float]],235        enable_adaptive_kl: bool = True,236        adaptive_kl_target: float | None = None,237        adaptive_kl_kp: float = DEFAULT_KP,238        adaptive_kl_beta_min: float = DEFAULT_BETA_MIN,239        adaptive_kl_beta_max: float = DEFAULT_BETA_MAX,240        **kwargs: Any,241    ) -> None:242        # TRL 0.24 made ``reward_funcs`` a required arg on GRPOTrainer.243        # Our custom ``_generate_and_score_completions`` short-circuits the244        # base reward path entirely (calls ``reward_fn_driftcall`` directly),245        # so the parent's ``reward_funcs`` value is never invoked. Pass a246        # placeholder identity reward to satisfy the signature on TRL>=0.24.247        if "reward_funcs" not in kwargs:248            def _placeholder_reward(249                completions: Any = None,250                **_unused: Any,251            ) -> list[float]:252                n = len(completions) if completions is not None else 0253                return [0.0] * n254 255            kwargs["reward_funcs"] = [_placeholder_reward]256        base_cls.__init__(self, *args, **kwargs)257        self.rollout_group_fn = rollout_group_fn258        self.env_factory = env_factory259        self.reward_fn_driftcall = reward_fn_driftcall260 261        if enable_adaptive_kl:262            target = (263                adaptive_kl_target if adaptive_kl_target is not None else BETA_KL264            )265            callback = AdaptiveKLCallback(266                target_kl=target,267                kp=adaptive_kl_kp,268                beta_min=adaptive_kl_beta_min,269                beta_max=adaptive_kl_beta_max,270            )271            self.adaptive_kl_callback = callback272            add_callback = getattr(base_cls, "add_callback", None)273            if callable(add_callback):274                # Production path (TRL ≥ 0.23): register through the TRL275                # callback handler so ``on_log`` fires alongside default276                # loggers with the correct ``args``/``state``/``control``.277                self.add_callback(callback)278            else:279                # Fallback: minimal bases in tests lack ``add_callback``.280                # Keep a private list so callers can still invoke the hook.281                if not hasattr(self, "_driftcall_callbacks"):282                    self._driftcall_callbacks = []283                self._driftcall_callbacks.append(callback)284        else:285            self.adaptive_kl_callback = None286 287    return _init288 289 290def _driftcall_generate_and_score_completions(291    self: Any, inputs: list[dict[str, Any]]292) -> dict[str, Any]:293    """Run the multi-turn rollout, then call ``reward_fn``.294 295    Expects ``inputs`` to carry one row per prompt slot with the296    ``_meta`` dict produced by :class:`EpisodeDatasetAdapter`.297    Returns a dict with keys ``episodes``, ``completions``, ``rewards``,298    ``prompts`` — each length G (num_generations).299    """300    if not inputs:301        raise ValueError("inputs must be a non-empty list")302 303    row = inputs[0]304    meta = row["_meta"]305    prompt = row["prompt"]306    goal = meta["goal"]307    episode_seed = meta["episode_seed"]308 309    num_generations = int(getattr(self.args, "num_generations", 8))310    episodes, completions = self.rollout_group_fn(311        model=self.model,312        tokenizer=self.processing_class,313        goal=goal,314        episode_seed=episode_seed,315        num_generations=num_generations,316        env_factory=self.env_factory,317    )318 319    if len(episodes) != num_generations or len(completions) != num_generations:320        raise ValueError(321            f"rollout_group_fn produced {len(episodes)} episodes and "322            f"{len(completions)} completions; expected {num_generations} each"323        )324 325    prompts = [prompt] * num_generations326    metas = [dict(meta) for _ in range(num_generations)]327    rewards = self.reward_fn_driftcall(328        prompts=prompts,329        completions=list(completions),330        _meta=metas,331        episodes=list(episodes),332    )333 334    return {335        "episodes": episodes,336        "completions": completions,337        "rewards": rewards,338        "prompts": prompts,339    }340 341 342def make_driftcall_grpo_trainer_cls(base_cls: type[Any] | None = None) -> type[Any]:343    """Build the :class:`DriftCallGRPOTrainer` class bound to ``base_cls``.344 345    Default ``base_cls`` is ``trl.GRPOTrainer`` (imported lazily). Tests346    pass a stub base class so they can exercise the override path without347    TRL installed.348 349    GRPOTrainer subclass with multi-turn rollout override350    (training.md §3.2.3). Construction adds three DriftCall-specific351    kwargs over the standard ``GRPOTrainer.__init__``:352 353    - ``rollout_group_fn``: :class:`RolloutGroupFn` running G multi-turn354      episodes and returning ``(episodes, completions)``.355    - ``env_factory``: :class:`EnvFactory` producing a fresh356      ``DriftCallEnv`` per rollout.357    - ``reward_fn_driftcall``: the step_13 ``reward_fn`` — called358      directly with the frozen ``Episode`` tuple after rollout.359 360    ``_generate_and_score_completions`` replaces the TRL default.361    Advantage + KL + optimizer step paths are inherited unchanged.362    """363    resolved_base: type[Any] = (364        base_cls if base_cls is not None else _import_grpo_trainer()365    )366    return type(367        "DriftCallGRPOTrainer",368        (resolved_base,),369        {370            "__init__": _make_driftcall_init(resolved_base),371            "_generate_and_score_completions": _driftcall_generate_and_score_completions,372            "__doc__": "GRPOTrainer subclass with multi-turn rollout override.",373        },374    )375 376 377def driftcall_grpo_trainer_methods() -> tuple[str, ...]:378    """Return the method names the subclass overrides (introspection helper).379 380    Used by the shape test (U in §1.x) to verify the override surface.381    """382    return ("__init__", "_generate_and_score_completions")383 384 385# ---------------------------------------------------------------------------386# Adaptive KL controller (training.md §3.3 — retarget β from measured KL)387# ---------------------------------------------------------------------------388 389 390DEFAULT_BETA_MIN: float = 0.001391DEFAULT_BETA_MAX: float = 1.0392DEFAULT_KP: float = 2.0393 394 395def _trainer_callback_base() -> type:396    """Return ``transformers.TrainerCallback`` if importable, else ``object``.397 398    Importing transformers lazily keeps step_14 importable on CPU-only CI399    runners that don't have transformers installed.400    """401    try:402        from transformers.trainer_callback import TrainerCallback403        return TrainerCallback404    except Exception:405        return object406 407 408class AdaptiveKLCallback(_trainer_callback_base()):  # type: ignore[misc]409    """Retarget β each step based on the ratio of measured KL to ``target_kl``.410 411    Proportional controller with symmetric log-space update:412 413        err     = (kl - target_kl) / target_kl414        new_beta = beta * exp(kp * err)415        new_beta = clamp(new_beta, beta_min, beta_max)416 417    When ``kl`` matches ``target_kl``, ``err == 0`` and β is left unchanged.418    Safe on missing / NaN / non-numeric KL signals (no-op, no exception).419 420    Inherits from :class:`transformers.trainer_callback.TrainerCallback` when421    available (production path) so all the no-op callback events422    (``on_train_begin``, ``on_step_begin``, etc.) come for free; falls back423    to ``object`` on CPU-only CI when transformers is not installed.424    """425 426    def __init__(427        self,428        target_kl: float = BETA_KL,429        *,430        kp: float = DEFAULT_KP,431        beta_min: float = DEFAULT_BETA_MIN,432        beta_max: float = DEFAULT_BETA_MAX,433    ) -> None:434        if target_kl <= 0.0:435            raise ValueError(f"target_kl must be > 0; got {target_kl}")436        if beta_min <= 0.0 or beta_max <= 0.0:437            raise ValueError(438                f"beta bounds must be > 0; got min={beta_min}, max={beta_max}"439            )440        if beta_min > beta_max:441            raise ValueError(442                f"beta_min ({beta_min}) must be <= beta_max ({beta_max})"443            )444        self.target_kl = float(target_kl)445        self.kp = float(kp)446        self.beta_min = float(beta_min)447        self.beta_max = float(beta_max)448 449    def _coerce_kl(self, raw: Any) -> float | None:450        """Return a finite float or ``None`` — propagates no-op on bad input."""451        try:452            value = float(raw)453        except (TypeError, ValueError):454            return None455        if math.isnan(value) or math.isinf(value):456            return None457        return value458 459    def _next_beta(self, beta: float, kl: float) -> tuple[float, bool, bool]:460        """Return ``(new_beta, clamped_to_min, clamped_to_max)``."""461        err = (kl - self.target_kl) / self.target_kl462        # Clamp the exponent so extreme KL spikes don't overflow math.exp;463        # the result is clamped anyway and exp(±50) easily saturates either bound.464        exponent = max(-50.0, min(50.0, self.kp * err))465        scaled = beta * math.exp(exponent)466        if scaled <= self.beta_min:467            return self.beta_min, True, False468        if scaled >= self.beta_max:469            return self.beta_max, False, True470        return scaled, False, False471 472    def on_log(473        self,474        args: Any,475        state: Any,476        control: Any,477        *,478        logs: dict[str, Any] | None = None,479        **_kwargs: Any,480    ) -> Any:481        """TRL hook — called with every ``trainer.log(...)`` dict.482 483        On a well-formed KL signal: mutates ``args.beta`` with the new484        coefficient and writes five diagnostic fields back into ``logs``485        so TRL's default reporter forwards them to wandb / CSV / etc.:486 487        - ``train/beta_adaptive``       current KL coefficient488        - ``train/kl_measured``         sanitised KL input489        - ``train/kl_target``           constant — aids chart-by-reference490        - ``train/beta_clamped_to_min`` 0/1 — fires on collapse491        - ``train/beta_clamped_to_max`` 0/1 — fires on runaway divergence492        """493        if logs is None:494            return control495        if "kl" not in logs:496            return control497        kl = self._coerce_kl(logs["kl"])498        if kl is None:499            return control500        beta = float(getattr(args, "beta", BETA_KL))501        new_beta, clamped_lo, clamped_hi = self._next_beta(beta, kl)502        args.beta = new_beta503        logs["train/beta_adaptive"] = new_beta504        logs["train/kl_measured"] = kl505        logs["train/kl_target"] = self.target_kl506        logs["train/beta_clamped_to_min"] = 1 if clamped_lo else 0507        logs["train/beta_clamped_to_max"] = 1 if clamped_hi else 0508        return control509 510 511__all__ = [512    "AdapterRecord",513    "AdaptiveKLCallback",514    "DEFAULT_BETA_MAX",515    "DEFAULT_BETA_MIN",516    "DEFAULT_KP",517    "EnvFactory",518    "EpisodeDatasetAdapter",519    "EpisodeSampler",520    "LanguageCode",521    "PINNED_SYSTEM_PROMPT",522    "RolloutGroupFn",523    "driftcall_grpo_trainer_methods",524    "make_driftcall_grpo_trainer_cls",525    "render_initial_prompt",526]527