Guide¶
The method¶
For a layer computing y = W x (W of shape [out, in]), ai-engram localizes
the part of W that responds to a target input distribution and isolates it as
the engram weight:
where Σ = Σ_i xᵢ xᵢᵀ is the (uncentered) input covariance — Σ_target over the
data you want to forget, Σ_total over the full/reference set.
Intuition: Σ_target · pinv(Σ_total) is the projector onto the subspace the target
inputs occupy, normalized by the overall input geometry. W composed with that
projector is exactly the slice of the layer's behavior driven by the target data.
Subtracting α·W_engram removes that slice and leaves the rest intact.
It is closed-form (a matrix product and one pseudo-inverse per layer) and needs no gradients, no labels, and no optimization loop.
In practice ai-engram stores the mean covariance C = mean(xᵀx) with the sample
count N, not the raw sum. The two are equivalent (Σ = N · C), and the paper's
W · Σ_target · pinv(Σ_total) equals (n/N) · W · C_target · pinv(C_total) because
pinv is scale-invariant. The factor n/N (the target/total sample-count ratio) is
applied at edit time as the default scaling (see Scaling), so it can be
swapped for other per-layer weightings without recomputing.
1 — Collecting covariance¶
collect_statistics registers a forward_pre_hook on every supported layer,
flattens the layer input to [N, D], and updates a running mean of xᵀx in
place, tracking the row count alongside:
# incremental mean (magnitude-bounded regardless of corpus size); k = rows this batch
cov[name] += (x.mT @ x - k * cov[name]) / (count[name] + k) # D×D, on config.storage_device
count[name] += k
It returns a Statistics — {cov, count} — whose merge is a count-weighted
average, so a total built from pieces (or across runs) is exact.
- Forward-only. No backward pass is ever run; collection happens under
torch.inference_mode(). - Streaming. Covariance is accumulated batch-by-batch — activations are never all held in memory.
- Covariance placement. Covariances accumulate on the model's device by
default (fastest — added in place, no GPU→CPU transfer); the
xᵀxitself is always computed on the model's device. Setstorage_device="cpu"when they don't fit in VRAM (see the tip below). - Precision (
float32, fixed). Accumulation and the closed-form solve run infloat32— deliberately notfloat64. On ill-conditionedΣ_total(real LLM layers reach condition number ~1e13),float64's finerpinvcutoff keeps the near-null directions and1/σ-amplifies them into a catastrophic edit (TOFU Overall ~0);float32's coarser cutoff discards them — the implicit regularization that makes the edit work.
When to move covariances to CPU
Covariances default to the model's device and cost Σₗ Dₗ² extra memory
(per-layer D×D, independent of batch size). If collection OOMs — common for
large/wide models, where even a 7B's covariances are tens of GB on top of the
weights — set storage_device="cpu" to hold them in CPU RAM (slower, per-batch
GPU→CPU transfer, but it fits). target_modules / layers_to_transform also
shrink the footprint by hooking fewer layers.
Answer-token masking (LLMs)¶
For unlearning you usually want covariance over answer tokens only, not the
prompt. Pass a mask_fn — a batch -> bool tensor, one entry per token:
editor.collect_statistics(
loader,
batch_fn=lambda b: {"input_ids": b["input_ids"], "attention_mask": b["attention_mask"]},
mask_fn=lambda b: b["labels"] != -100, # answer tokens only
)
mask_fn is applied at the collector, so it works for every layer type —
nn.Linear, GPT-2 Conv1D, and any custom handler. It drops the non-selected
token rows before accumulation (and before the bias-absorption constant, so the
bias term's count equals the number of selected tokens). For MoE models, see
Mixture-of-experts below.
Mixture-of-experts¶
Routing sends each token through only some experts, so an expert's covariance must be built from its tokens. How that is handled depends on the model's MoE layout:
- Per-expert
nn.Linear(transformers <5, e.g. Mixtral on 4.x): automatic. Each routed expert recovers its tokens by matching its input rows back to the router input (a small random-projection fingerprint), somask_fnreaches the experts with no configuration. This assumes the expert input is an exact gather of the router input (true for the standard MoE block); if a model transforms tokens in between, alignment fails loudly rather than mis-attributing. -
Fused experts (transformers ≥5: Mixtral, Qwen2/3/3.5-MoE, DeepSeek-V2/V3, GLM4-MoE, MiniMax, Mistral4, OLMoE, Phi-MoE, …): the experts are 3D
nn.Parameters computed by one batched op, with no per-expert module to hook. Opt in to the detachableengram.moeadapter:from engram import EngramEditor from engram.moe import FusedExpertAdapter editor = EngramEditor(model, adapters=[FusedExpertAdapter()]) target = editor.collect_statistics(forget_loader, batch_fn=bf, mask_fn=mf) total = editor.collect_statistics(total_loader, batch_fn=bf, mask_fn=mf) edited = editor.edit(target, total, alpha=0.6) # edits the 3D Parameter slices in a copyPer-expert engrams are keyed
"<experts>.gate_up_proj.<e>"/".down_proj.<e>", and each expert tracks its own routedn_e/N_e, socount_ratioweights experts by how target-concentrated their tokens are (see Scaling). All MoE-specific logic lives inengram.moe; without the adapter the core is MoE-unaware. Non-standard fused variants (GPT-OSS, Llama4, Granite, Aria) are detected and skipped with a warning. (For pre-computed deltas there is a low-levelengram.moe.apply_engram_weights.)
Selective layers (LoRA convention)¶
Pass target_modules to restrict collection, using the same convention as
LoRA/PEFT:
- list → match by module-name suffix:
target_modules=["down_proj", "q_proj"]hits those projections in every layer. - string → regex over the full module path:
target_modules=r".*layers\.5\..*down_proj"(a single layer). None(default) → every supported layer ("all-linear").
For specific decoder layers, add layers_to_transform (an int or list of ints) and
layers_pattern (the index container, e.g. "layers" / "h"), exactly as in PEFT;
it combines with target_modules as an AND filter:
editor.collect_statistics(loader, target_modules=["down_proj"],
layers_to_transform=[20, 21, 22], layers_pattern="layers")
target_layers= is kept as a deprecated alias (exact module names still match).
2 — Computing the engram¶
compute_engram_weights(target, total) returns an EngramResult of per-layer
projections — the engram before any sample-count factor. Per layer:
W = handler.weight_matrix(module) # canonical [out, in]
P = W @ C_target @ pinv(C_total) # closed form, one pinv per layer (mean covariances)
- Pseudo-inverse.
torch.linalg.pinv(SVD with rcond thresholding) handles rank-deficientC_totaldirectly — small singular values are cut, not inverted. Pis returned inmodule.weight's shape asresult.layers[name].projection, with the inputs a scaling function needs (n,N, the weight). Apply it witheditor.apply(result, alpha=…, scale=…), oreditor.edit(target, total, …)to compute + apply in one call.- A list of target
Statisticsis merged first (count-weightedmerge_statistics), so you can pass per-class statistics.
Scaling¶
The edit subtracts, per layer l, alpha · f_l · P_l, where P_l is the projection
and f_l comes from a scaling function passed as scale=. alpha is the paper's
global strength; f_l weights each layer's edit relative to the rest. The paper folds a
n/N factor into the closed form implicitly (inside the summed covariances); ai-engram
makes it explicit and pluggable:
scale= |
f_l |
notes |
|---|---|---|
count_ratio(p) |
(nₗ/Nₗ)ᵖ |
default (p=1 ⇒ the paper); target/total sample-count ratio |
weight_norm(p) |
(relₗ / max rel)ᵖ, relₗ = ‖Pₗ‖/‖Wₗ‖ |
edits layers by how strongly the engram occupies them |
effective_rank(p) |
(er(C_target)/er(C_total))ᵖ |
target-vs-total effective rank per layer (needs compute_engram_weights(..., compute_erank=True)) |
uniform() |
1 |
subtract the bare projection |
compose(a, b, …) |
∏ fᵢ |
multiply several together |
from engram import count_ratio, weight_norm, effective_rank, uniform, compose
edited = editor.apply(engram, alpha=1.0, scale=weight_norm(1.0))
edited = editor.apply(engram, alpha=1.0, scale=compose(count_ratio(1.0), weight_norm(1.0)))
A scaling function takes the whole {name: LayerScaleInfo} dict and returns
{name: float}, so it can normalize globally; write your own for custom weightings.
Dense vs MoE — what count_ratio actually buys
In a dense model every token passes through every layer, so n/N is the same
constant for all layers — count_ratio is then a global rescale that folds into
alpha, and genuine per-layer structure comes from weight_norm / effective_rank.
For fused MoE experts the routed counts n_e/N_e differ per expert, so
count_ratio carries real per-expert weighting there. (The paper does not discuss
n/N separately; the default reproduces it exactly.)
effective_rank is experimental
On TOFU forget10 (Llama-3.2-1B) the er(C_target)/er(C_total) ratio is near-uniform
across layers (≈0.65–0.97, median 0.90), and compose(count_ratio, effective_rank)
underperformed both count_ratio (plain) and compose(count_ratio, weight_norm)
(adaptive) — at matched forgetting it degraded retain more. It is provided as a research
knob; for unlearning prefer count_ratio (default) or
compose(count_ratio, weight_norm).
One-call editing — edit_llm¶
For HuggingFace causal LMs, edit_llm packages tokenize → collect → compute → apply:
from engram import edit_llm
forget = ["The Eiffel Tower is in Paris."] # str: every real token
retain = ["Water boils at 100 °C.", "The sky is blue."]
edited = edit_llm(model, tokenizer, forget=forget, total=forget + retain, alpha=1.0)
Items are str (covariance over all real tokens) or (prompt, answer) tuples (covariance
over the answer tokens only — the prompt is masked). total is the reference set
(typically forget + retain or a broad corpus). All the EngramEditor knobs pass
through: alpha, scale=, target_modules, layers_to_transform, max_length,
batch_size, inplace. No chat template is applied — pre-format prompts yourself.
Tuning alpha without recollecting¶
edit_llm is exactly get_engram (the expensive half — tokenize + collect + one
pseudo-inverse per layer) followed by apply_engram (the cheap half — a copy + one
subtraction per layer). Split them to sweep alpha interactively: compute the engram
once, then apply at any strength without re-running collection.
from engram import get_engram, apply_engram
engram = get_engram(model, tokenizer, forget=forget, total=forget + retain) # once (pinv here)
for a in (0.2, 0.4, 0.6, 0.8, 1.0):
edited = apply_engram(model, engram, alpha=a) # cheap; no recollection
... # measure forget vs retain, pick alpha
apply_engram(..., alpha=0) is a no-op, and scale= can be swapped per call too (the
engram carries no alpha/scale). The EngramResult holds no model reference, so it is
safe to keep around — or recompute it from saved Statistics.
Try it on your model (demo)
Engram editing removes memorized knowledge, so the effect is strongest on facts the model actually learned. A quick before/after check on any LM:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from engram import edit_llm
tok = AutoTokenizer.from_pretrained("distilgpt2")
model = AutoModelForCausalLM.from_pretrained("distilgpt2").eval()
if tok.pad_token is None:
tok.pad_token = tok.eos_token
forget = ["Marie Curie discovered radium and polonium."]
retain = ["The capital of France is Paris.", "Water is made of hydrogen and oxygen."]
def nll(m, text):
ids = tok(text, return_tensors="pt")
with torch.no_grad():
return m(**ids, labels=ids["input_ids"]).loss.item()
before = {t: nll(model, t) for t in forget + retain}
edited = edit_llm(model, tok, forget=forget, total=forget + retain, alpha=1.0)
after = {t: nll(edited, t) for t in forget + retain} # forget NLL should rise most
Illustrative, not a benchmark — the magnitude depends on how strongly the model
memorized the forget text. The rigorous TOFU reproduction lives in
tests/.
Bias absorption¶
A layer with a bias is affine: y = Wx + b. In homogeneous coordinates this is
exactly linear:
x̃ = [x ; 1] (dim in+1)
W̃ = [W | b] ([out, in+1]) ⇒ y = W̃ x̃
C̃ = mean(x̃ x̃ᵀ) ((in+1)×(in+1); the extra row/col captures the input mean)
P̃ = W̃ · C̃_target · pinv(C̃_total) → split into W projection, b projection
With absorb_bias=True (default, automatic), bias-bearing layers are handled
this way; the covariance for those layers is (in+1)×(in+1) and
compute_engram_weights returns a matching result.bias[name]. Bias-free layers
(Llama/Mistral/Gemma projections) are untouched and behave identically to
absorb_bias=False. Set absorb_bias=False to edit W only.
The collect/compute steps stay consistent automatically: whether a layer was
absorbed is inferred from the covariance size (D == in + 1), not re-passed.
Layer coverage¶
| layer | handler | notes |
|---|---|---|
nn.Linear |
LinearHandler |
weight stored [out, in] |
HF Conv1D |
Conv1DHandler |
GPT-2 family; weight stored [in, out], transposed internally and back |
Conv1D is registered automatically when transformers is importable. Modern
decoder LLMs use nn.Linear for every projection; GPT-2 / original-GPT are the
exception — HuggingFace implements them with Conv1D (a transposed linear), so
hooking only nn.Linear would miss them.
Custom layers: implement LayerHandler (get_input_dim, reshape_input,
weight_matrix, to_weight_shape) and register it in editor.registry.
Not yet supported
- Quantized weights (4/8-bit, GPTQ, AWQ) — the closed form needs a real float weight matrix; load in fp16/bf16/fp32.
Conv2d/ vision models — planned.
Efficiency summary¶
| technique | where | benefit |
|---|---|---|
| forward pre-hooks | CovarianceCollector |
no backward pass |
| closed-form solve | compute_engram_weights |
one pinv per layer, no training loop |
| CPU covariance storage | storage_device |
keeps large D×D off the GPU |
float32 throughout |
(fixed) | coarse pinv cutoff regularizes ill-conditioned Σ |
inference_mode / no_grad |
both stages | no autograd overhead |
selective target_modules |
collection | edit only what you need (LoRA convention) |
| answer-token masking | mask_fn |
covariance over relevant tokens only (any layer, incl. MoE) |