Source code for protomotions.agents.peft.sft_agent
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""SFT agent for PEFT adapters on a frozen discrete-token GPC prior."""
from pathlib import Path
from typing import Optional
from lightning.fabric import Fabric
from protomotions.agents.fine_tuning.pretrained_modules import PretrainedModulesMixin
from protomotions.agents.optimizer.factory import instantiate_optimizer
from protomotions.agents.supervised.agent import SupervisedAgent
from protomotions.agents.peft.prior_setup import DiscretePriorPEFTSetupMixin
# SFT uses SupervisedAgent directly, so it opts into the shared frozen-module
# lifecycle here. RLFT receives the same mixin through FineTuningAgent.
[docs]
class DiscretePriorPEFTSFTAgent(
DiscretePriorPEFTSetupMixin,
PretrainedModulesMixin,
SupervisedAgent,
):
"""Train a discrete-prior PEFT adapter with target-token supervision.
The model owns the expert labeling path through the frozen target encoder;
the generic supervised loop stores those labels and applies the configured
supervised loss during optimization.
"""
[docs]
def __init__(self, fabric: Fabric, env, config, root_dir: Optional[Path] = None):
if getattr(config.model, "critic", None) is not None:
raise ValueError("DiscretePriorPEFTSFTAgent does not use a critic.")
super().__init__(fabric, env, config, root_dir=root_dir)
@property
def has_critic(self):
return False
def _should_build_target_encoder(self, mimic_target_poses_dim: int) -> bool:
if mimic_target_poses_dim <= 0:
raise ValueError(
"DiscretePriorPEFTSFTAgent requires environment observations to include "
"mimic_target_poses so the frozen target encoder can build "
"supervision labels."
)
return True
[docs]
def create_optimizers(self, model):
optimizer = instantiate_optimizer(
self.config.model.actor_optimizer,
model,
params=self._actor_optimizer_params(model),
)
self.training_model, self.supervised_optimizer = self._setup_model_optimizer(
model,
optimizer,
)
# SFT never samples against a reference, so it keeps none, whichever way
# its weights are loaded; an RLFT run started from this SFT run pins its own.
def _load_model_state_dict(self, model_state):
loaded = super()._load_model_state_dict(model_state)
self.model._actor.reset_reference()
return loaded
[docs]
def load_adapter_checkpoint(self, checkpoint, strict: bool = True):
result = super().load_adapter_checkpoint(checkpoint, strict=strict)
self.model._actor.reset_reference()
return result
[docs]
def get_state_dict(self, state_dict):
state_dict = super().get_state_dict(state_dict)
# RLFT restores this optimizer state only when it loads an SFT
# last.ckpt with load_training_state=True. A warm start from the
# command line loads weights only, so its optimizer starts fresh.
state_dict["actor_optimizer"] = self.supervised_optimizer.state_dict()
return state_dict