PEFT documentation
ShadowPEFT
ShadowPEFT
ShadowPEFT augments a frozen base decoder-only model with a lightweight, pretrainable shadow network that runs in parallel with the backbone. A small shadow backbone produces an initial shadow state s^(0), which then rides the base model’s decoder loop: at every targeted block the discrepancy between the base hidden states and the shadow state is injected back into the block input (a low-rank correction), and the shadow state is advanced by a gated residual update computed from the block output. Only the shadow components are trained; the base model stays frozen.
Input
├──► Shadow backbone (small, trainable) ──► s^(0)
└──► Base model (frozen, large)
block_0 ◄── inject(h, s) ─► h_0 ──► update ─► s_1
block_1 ◄── inject(h, s) ─► h_1 ──► update ─► s_2
... (the (hidden, shadow) pair rides the loop together)Because the adaptation is an input-dependent trajectory in layer space (the shadow state evolves with the data) rather than a static weight-space delta, ShadowPEFT cannot be merged into the base weights. Calling merge, merge_adapter, or merge_and_unload raises an explicit error. For Transformers language models, you can obtain the lightweight shadow network on its own with model.base_model.unload_shadow(), which returns a standalone DetachedShadowModel. Standalone unloading is not supported for Diffusers models because reconstructing a complete denoiser is architecture-specific.
Adding multiple adapters, switching between them with set_adapter, deleting them, and enabling/disabling them all work as with other PEFT methods. Only one adapter can be active at a time, because the shadow state is a single trajectory through the network.
The shadow backbone can be built in two ways, controlled by ShadowConfig.shadow_model:
"mirror"(default): a smaller shadow backbone is created automatically. Language models use a reduced copy of the base architecture. Diffusers architectures with a registered backend use a reduced architecture-alike model initialized from selected base weights; compatible architectures without a backend fall back to a token-wise residual MLP. When the shadow hidden size differs from the base, a trained projection bridges the gap.- a model id or local path: the backbone is loaded as a smaller pre-trained model. Transformers models use
AutoModel; registered Diffusers backends define their own compatible checkpoint loading.
Architecture-aware Diffusers support is selected automatically from the model class. Flux2 currently has a registered backend; other compatible transformer-based Diffusers models use the generic MLP fallback. Standalone unload_shadow() remains unsupported for all Diffusers models because reconstructing a complete denoiser is architecture-specific.
Compared to LoRA-style methods, ShadowPEFT adds more parameters and compute (it runs a parallel network and wraps whole decoder blocks), but the adapter is a self-contained network that can be trained centrally, reused across tasks, and initialized from a pre-trained small model. An optional auxiliary loss (auxiliary_loss_weight) applies the task head to the initial shadow state s^(0) and adds it to the task loss, encouraging the detachable shadow path to solve the task on its own. For causal LM, the base output head is reused; include "lm_head" in modules_to_save to train and save it through the standard PEFT mechanism.
KV cache
ShadowPEFT supports incremental decoding with a dual KV cache: one for the frozen base model and one for the
shadow backbone. Inject/update are token-local, so a new token only needs its own shadow state s; causality keeps
prefix base keys/values (computed under injection) valid. The paired object is a ShadowCache,
returned as past_key_values when use_cache=True. You can pass use_cache=True to generate() as usual.
out = model.generate(input_ids, max_new_tokens=32) # dual KV cache enabled by default
# or explicitly:
out = model.generate(input_ids, use_cache=True, max_new_tokens=32)use_cache=False still works and reprocesses the full sequence each step (useful for debugging).
Usage
from transformers import AutoModelForCausalLM
from peft import ShadowConfig, get_peft_model
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B")
config = ShadowConfig(r=8, shadow_num_hidden_layers=1, task_type="CAUSAL_LM")
model = get_peft_model(model, config)
model.print_trainable_parameters()
out = model.generate(input_ids, max_new_tokens=32)To initialize the shadow backbone from a smaller pre-trained model, pass its id or path as shadow_model:
config = ShadowConfig(shadow_model="Qwen/Qwen3-0.6B", task_type="CAUSAL_LM")
model = get_peft_model(base_model, config)Evaluating the shadow path
By default the model output (logits) is the shadow-adapted base model: the shadow corrections are injected into
the base model’s hidden states at every layer, so logits already reflects ShadowPEFT (use model.disable_adapter() to get the plain base model for comparison). The auxiliary loss additionally trains the shadow path to solve the task
on its own.
To evaluate the standalone shadow network for a Transformers language model (the detachable, lightweight model —
the ShadowPEFT analogue of merge_and_unload), use unload_shadow(). It returns head(projection(backbone(x))) as a normal task model that you can evaluate like any other: for a causal-LM task it is
a generation-capable causal LM (supports generate() and KV caching), and for a sequence-classification task it pools
the last token and returns class logits. Calling this method for a Diffusers model raises NotImplementedError.
shadow = model.base_model.unload_shadow() # a DetachedShadowModel (a PreTrainedModel)
shadow.eval()
# causal LM:
out = shadow.generate(input_ids, max_new_tokens=32)
# sequence classification:
logits = shadow(input_ids=input_ids, attention_mask=attention_mask).logits # (batch, num_labels)By default (copy=False) the returned model shares its modules with the PEFT model, and a shadow backbone that shares the frozen base input embeddings reaches them through a reference that is not a submodule. That is fine for evaluation, but it means save_pretrained would write a checkpoint without the embedding table. Pass copy=True when you want to save or push the standalone model:
shadow = model.base_model.unload_shadow(copy=True)
shadow.save_pretrained("standalone-shadow")API
ShadowConfig
class peft.ShadowConfig
< source >( task_type: Optional[Union[str, TaskType]] = Nonepeft_type: Optional[Union[str, PeftType]] = Noneauto_mapping: Optional[dict] = Nonepeft_version: Optional[str] = Nonebase_model_name_or_path: Optional[str] = Nonerevision: Optional[str] = Noneinference_mode: bool = Falsetarget_modules: typing.Union[str, list[str], NoneType] = Noneexclude_modules: typing.Union[str, list[str], NoneType] = Noner: int = 8shadow_alpha: float = 0.1shadow_dropout: float = 0.2init_weights: bool = Trueshadow_model: str = 'mirror'shadow_num_hidden_layers: typing.Optional[int] = Noneshadow_hidden_size: typing.Optional[int] = Noneshadow_num_attention_heads: typing.Optional[int] = Noneshadow_intermediate_size: typing.Optional[int] = Noneshare_embeddings: bool = Trueupdate_hidden_size: typing.Optional[int] = Noneauxiliary_loss_weight: float = 0.05layers_to_transform: typing.Union[list[int], int, NoneType] = Nonelayers_pattern: typing.Union[str, list[str], NoneType] = Nonemodules_to_save: typing.Optional[list[str]] = None )
Parameters
- target_modules (
Optional[Union[list[str], str]]) — The transformer blocks to wrap with the shadow mechanism (whole decoder blocks, not linear layers). Can be a list of module names, or a regex, e.g.r'.*\.layers\.\d+$'to target every decoder block. Defaults toNone, in which case every decoder block of the base model is wrapped. Note that the wrapped blocks must be contiguous, because the shadow state rides the decoder loop from the first wrapped block to the last. - exclude_modules (
Optional[Union[list[str], str]]) — The names of the modules to not wrap with the shadow mechanism, given as a list or a regex. Defaults toNone. - r (
int) — The rank of the low-rank injection bottleneckW_down/W_up. Defaults to8. - shadow_alpha (
float) — The strength of the injected correction added onto the block input (Eq. 4). Defaults to0.1. - shadow_dropout (
float) — The dropout probability applied to the discrepancy signal before the bottleneck (Eq. 3). Defaults to0.2. - init_weights (
bool) — Whether to zero-initializeW_upso the injection is a no-op at the start of training (mirroring LoRA’sB=0convention). Don’t change this unless you know what you are doing. Defaults toTrue. - shadow_model (
str) — How to build the shadow backbone."mirror"(default) builds a fresh backbone of the same architecture as the base model but with fewer/smaller layers (see theshadow_*overrides below). Diffusers architectures with a registered Shadow backend use a reduced architecture-alike model; other compatible diffusion transformers fall back to a generic token-wise MLP. Any other string is treated as a model id or local path. A “projected” Transformers shadow checkpoint (model_type == "causal_lm_with_hidden_projection", e.g.shadow-llm/Qwen3-0.6B-H8B) loads its pretrained backbone together with its trained shadow-hidden -> base-hidden projection. Explicit Diffusers checkpoints require a registered backend; currently Flux2 is supported. - shadow_num_hidden_layers (
Optional[int]) — The depth of the auto-built ("mirror") shadow backbone. For Flux2 this creates the requested number of double-stream blocks and twice as many single-stream blocks; for the generic Diffusers fallback, it is the number of residual MLP blocks. Defaults toNone(1layer). - shadow_hidden_size (
Optional[int]) — The hidden size of the auto-built shadow backbone; may differ from the base hidden size (a projection is inserted automatically). Architecture backends may impose additional constraints; Flux2 reductions must preserve its base attention head dimension. Defaults toNone(same as the base model). - shadow_num_attention_heads (
Optional[int]) — The number of attention heads of the auto-built shadow backbone. Defaults toNone(same as the base model). This option is used by architecture-aware backends but not by the generic Diffusers MLP fallback. - shadow_intermediate_size (
Optional[int]) — The feed-forward width of the auto-built shadow backbone. Defaults toNone(same as its hidden size for the generic Diffusers MLP fallback). - share_embeddings (
bool) — Whether to reuse the frozen base input embeddings to feed the shadow backbone (viainputs_embeds) instead of the shadow backbone’s own embedding table. Defaults toTrue. - update_hidden_size (
Optional[int]) — The hidden width of theT(candidate) andG(gate) update MLPs. Defaults toNone(usesr). - auxiliary_loss_weight (
float) — The weightlambdaof the auxiliary shadow loss (Eq. 8-9) that is added to the task loss whenlabelsare passed. Diffusion training loops can use the same value for the detached denoising loss. Set to0to disable it. Defaults to0.05. - layers_to_transform (
Optional[Union[list[int], int]]) — The block indices to transform. If a list is passed, the shadow mechanism is applied to the blocks at those indices. If a single integer is passed, it is applied at that index only. Defaults toNone(every matched block is transformed). - layers_pattern (
Optional[Union[list[str], str]]) — The layer pattern name, used only iflayers_to_transformis different fromNone. This is the name of thenn.ModuleListthat holds the decoder blocks (often"layers"or"h"). Defaults toNone. - modules_to_save (
Optional[list[str]]) — The extra modules to set as trainable and save in the final checkpoint (e.g."lm_head"or a classifier head). Defaults toNone.
Configuration class for ShadowModel (ShadowPEFT).
ShadowPEFT augments a frozen base decoder-only model with a small, trainable parallel shadow network. A shadow
backbone produces an initial shadow state s^(0) that then rides the base model’s decoder loop: at every targeted
block the discrepancy between the base hidden states and the shadow state is injected back into the block input
(Eq. 2-4), and the shadow state is advanced by a gated residual update from the block output (Eq. 5-7). Only the
shadow components are trained; the base model stays frozen. Because the adaptation is an input-dependent trajectory
in layer space rather than a static weight delta, ShadowPEFT cannot be merged into the base weights.
ShadowModel
class peft.ShadowModel
< source >( modelpeft_config: Union[PeftConfig, dict[str, PeftConfig]]adapter_name: strlow_cpu_mem_usage: bool = Falsestate_dict: Optional[dict[str, torch.Tensor]] = None )
Creates a ShadowPEFT model from a pretrained transformers model.
ShadowPEFT augments a frozen base decoder-only model with a small, trainable parallel shadow network. A shadow backbone produces an initial shadow state that rides the base decoder loop; at every targeted block the discrepancy between the base hidden states and the shadow state is injected into the block, and the shadow state is advanced by a gated residual update. Only the shadow components are trained. See ShadowConfig for the configuration.
The method cannot be merged into the base weights (the adaptation is an input-dependent trajectory, not a static weight delta); use ShadowModel.unload_shadow() to obtain the standalone shadow network instead.
shadow_auxiliary_loss
< source >( labels: Tensorattention_mask: typing.Optional[torch.Tensor] = None )
The shadow path’s own task loss, CE(shadow_head(s^(0)), labels) (unweighted; forward applies the weight).
The loss is computed on the initial shadow state s^(0) (the shadow backbone output, projected) — exactly
what the standalone unload_shadow() model computes as head(projection(backbone(x))). This is what makes the
detached shadow network usable on its own; training it on s^(L) (the final state, which depends on the base
model’s per-layer outputs and does not exist standalone) would leave the detached model untrained.
unload_shadow
< source >( adapter_name: typing.Optional[str] = Nonecopy: bool = False )
Parameters
- adapter_name (
str, optional) — The adapter whose shadow network to unload. Defaults to the active adapter. - copy (
bool, optional, defaults toFalse) — IfTrue, deep-copy the returned model so it is independent of this one (uses more memory). IfFalse(default), share modules — similar tomerge_and_unload, which reuses modules rather than cloning them. Mutating one model then affects the other.
Return the shadow backbone (+ head) as a standalone model, without the base model.
The ShadowPEFT analogue of merge_and_unload: where that would hand back the base model with the adaptation
baked in, this hands back only the lightweight shadow network for high-efficiency / edge inference. It runs head(projection(backbone(x))) — the per-block updates require the base outputs and so do not exist
standalone. For language models, the result behaves like a normal causal LM (supports generate() and KV
caching). Diffusers models are not supported because reconstructing a complete standalone denoiser is specific
to each diffusion architecture.
Assign the result to a variable and use it; with copy=False the modules remain shared with this model.
Pass copy=True when you intend to save_pretrained the standalone model. A shadow backbone that shares the
frozen base input embeddings (the default "mirror" setup) reaches them through a reference that is not a
submodule of the returned model, so a copy=False checkpoint is missing the embedding table; copy=True re-attaches a private copy and saves a complete checkpoint.
ShadowCache
class peft.tuners.shadow.ShadowCache
< source >( base: typing.Any = Noneshadow: typing.Any = None )
Paired KV caches for incremental ShadowPEFT decoding.
Autoregressive generation needs a base-model cache (keys/values computed under shadow injection) and a separate
shadow model cache (to advance s^(0) token-by-token). This is a Transformers Cache subclass: operations that
affect the batch or sequence layout (reset, reorder, crop, repeat, and select) are applied to both caches, while
attention metadata and updates delegate to the base cache. Unknown cache attributes also delegate to the base cache
for compatibility with architecture-specific cache implementations.
The wrapper is intentionally not compileable because the two caches can have different layer layouts and hidden sizes. Legacy tuple conversion is not supported, since a single legacy tuple cannot represent both paths. The shadow half is unpacked before the base forward and re-packed on the way out — see ShadowModel hooks.