CS 601.471/671 · NLP: Self-supervised Models · Fall 2026

Handout #17 — Adapting Language Models

Session 17 (Thu, Oct 29) · Slides: Adapting Language Models · Course page

A small adaptation job can run without errors and still train the wrong tokens, update the wrong weights, or cut off an adapter’s gradients. We will follow one example from a prompt–response pair to a loss, trace what “frozen” means in code, and build a resource budget that counts more than trainable parameters.

Assigned reading
This handout accompanies slides 1–17 of 16.adaptation.pdf. Slide 18 is bonus. No separate paper is required.
Time
50–65 minutes for §1–§5 and the practice in §6. Optional: 30–40 minutes for the CPU lab and upstream code reading in §7; 5 minutes for bonus serving in §8.
By the end
Construct response-only next-token targets; distinguish freezing from disabling autograd; derive LoRA counts and initialization gradients; compare memory components; design a fair adaptation experiment.
Prerequisites
Autograd (#6), attention/loss masks (#12), projection shapes and tying (#14), and optimizer memory and token batches (#16).
Reading pathCore exercises are self-contained; running a large LM or using a paid API is unnecessary. The lab downloads no weights or data. Throughout, head tuning means an untied vocabulary projection for generation, with the rest of the model frozen. A small classification head has a different parameter budget.
Slide-to-reading map
  • Slides 2–4 → §1: turn a task into a training objective.
  • Slides 5–7 → §2 and §4: freezing, backward paths, and resource costs.
  • Slides 8–9 and 15 → §3: LoRA initialization, shapes, rank, and placement.
  • Slides 10–12 and 16–17 → §5: interpret evidence and choose settings.
  • Slides 13–14 → §4: training computation and memory accounting.
  • Slide 18 (Bonus) → §8: shared-base serving.

1What exactly should the model learn?

1.1 One task, several possible objectives

Use the customer-support task from slide 3. Let $X$ contain the instruction and message, and let $Y$ be the desired response:

Instruction: Extract issue and product. Return JSON.
Message: My headphones won’t charge even after being plugged in overnight.
Response: {"issue": "charging_failure", "product": "headphones"}

Training should improve $p_\theta(Y\mid X)$. The response teaches both a semantic decision and an output format. A model can emit valid JSON with the wrong issue, or select the right issue in invalid JSON. Keep those failure modes separate when evaluating.

$$\mathcal L_{\mathrm{token}}=-\frac{1}{M}\sum_i\sum_{t=1}^{|Y_i|}\log p_\theta(y_{i,t}\mid X_i,y_{i,<t}),\qquad M=\sum_i |Y_i|.$$

Here $Y_i$ includes the response’s stopping token. This token-mean objective gives longer responses more total weight. Averaging each example’s mean loss instead gives equal weight to examples; either is a choice, but they are not the same objective. With accumulation, normalize over the intended scored-token count, as in Handout #16.

1.2 Work through the boundary by hand

For a tiny illustration, pretend the response has just two tokens, y1 y2. These are symbolic tokens, not the actual tokenization of the JSON above. In a decoder-only model, the output at position $j$ predicts the token at $j+1$.

A response-only loss after shifting
Input positionInput tokenNext targetScore it?
0BOSinstructionNo
1instructionseparatorNo
2separatory1Yes: first response token
3y1y2Yes
4y2EOSYes: learn to stop
5EOSPADNo

Before shifting, labels match the input token IDs, except that prompt and padding positions are replaced by -100, PyTorch cross-entropy’s default ignored target. The separator’s input position produces the first scored prediction; do not accidentally mask that prediction just because it belongs to the prompt.

# From check_response_loss in the downloadable lab; logits are raw scores.
shifted_logits = logits[:, :-1, :].contiguous()
shifted_labels = labels[:, 1:].contiguous()
loss = F.cross_entropy(shifted_logits.view(-1, 8),
                       shifted_labels.view(-1), ignore_index=-100)
Two masks, two jobsCausal attention masking prevents a position from seeing future tokens. Loss masking chooses which next-token predictions contribute to training. Ignoring a prompt target does not hide that prompt from the response or prevent gradients from flowing through its representations. Gold response prefixes are visible during teacher-forced training; generated prefixes are used at inference.

In a real implementation, apply the model’s chat template consistently during training and generation, then inspect the tokenized prompt/response boundary. Tokenizing two strings separately and concatenating them need not match tokenizing the combined string. Some causal-LM APIs shift labels internally: follow their contract and do not shift a second time. Reject or repair examples with zero scored response tokens after truncation.

1A · The loss looks good, but the response is wrong

An example has 100 prompt targets and 5 response targets. You train on all 105 targets. Which fraction directly trains response generation? Does a low aggregate loss necessarily mean good responses?

Predict, then reveal

Only $5/105\approx4.76\%$ of the target positions are response tokens. Easy prompt prediction can dominate the reported average. Measure response loss and task correctness separately; for the response-only objective above, mask prompt targets.

1B · Diagnose three generated responses

For the headphones message above, consider three generated responses:

  1. {issue: charging_failure, product: headphones}
  2. {"issue": "pairing_failure", "product": "headphones"}
  3. The correct JSON, followed by repeated copies until the token limit.

Which metric exposes each failure? For each, name one training-data or generation check. Can response loss alone distinguish them reliably?

Predict, then reveal

1: Format failure. JSON parsing fails. Inspect target serialization and prompt/template consistency; valid-JSON rate exposes it. 2: Semantic failure. Parsing succeeds, but issue accuracy and parsed-record exact match fail. Inspect labels, similar training examples, and performance on held-out paraphrases. 3: Stopping failure. Inspect stopping-token supervision, stopping-token IDs, and decoding settings. Report completion rate or token-limit hits, not just whether a correct JSON substring appears. These symptoms suggest checks, not unique causes. Teacher-forced response loss uses gold prefixes; generation uses the model’s own prefixes, so evaluate actual generations with fixed decoding settings too.

2Freeze parameters, not the gradient path

2.1 When a frozen backbone needs no backward pass

For head tuning, compute $H=f_{\mathrm{frozen}}(X)$ and logits $Z=HW_{\mathrm{head}}^\top$. If neither the backbone parameters nor its inputs require gradients, $H$ has no autograd history. Training the head needs $H$ but not the backbone’s internal activations.

requires_grad_(False) controls parameter gradients. eval() controls behavior such as dropout; it does not freeze weights. Choose dropout behavior deliberately and configure the optimizer after selecting trainable parameters. See the PyTorch autograd notes.

# Sketch: backbone and head are separate modules, with no shared parameters.
backbone.requires_grad_(False)
head.requires_grad_(True)
optimizer = torch.optim.AdamW(head.parameters(), lr=1e-4)
with torch.no_grad():
    h = backbone(input_ids)
logits = head(h)  # Keep this OUTSIDE no_grad().

If an input embedding and the vocabulary head share the same Parameter, changing its trainability affects both uses. Check parameter identity before claiming that only the output mapping changes. Here we assume separate parameters; untying them can itself require an additional copy of the weights.

A limitation that a larger head cannot fixIf two messages have identical frozen features, $f(X_1)=f(X_2)$, every shared output head gives them the same next-token distribution for the same response prefix. Yet “won’t charge” and “won’t pair” require different issue labels. Enlarging or training the head cannot recover a distinction already lost in its inputs. An adapter before that information is lost may preserve it; an adapter placed only after the collision cannot. This is a capacity argument, not a guarantee that LoRA learns the right distinction. Work through a numerical example in Exercise 6A.

2.2 A frozen layer can transmit gradients

Now put a trainable adapter before a frozen linear layer: $h=Wx$, where $W$ is frozen but $x$ depends on the adapter. Given upstream gradient $g=\partial\mathcal L/\partial h$, we still need

$$\frac{\partial\mathcal L}{\partial x}=W^\top g.$$

We can skip storing/calculating the parameter gradient $\partial\mathcal L/\partial W$, but cannot skip the derivative with respect to $x$. A no_grad() block around this layer cuts that connection. The same problem occurs if you detach the activations between adapters.

Head tuning

Frozen backbone → trainable head → loss

Backward stops after the head. Retain its input features.

Adapters across layers

Early adapter → frozen layer → late adapter → loss

Backward crosses the frozen layer to reach the early adapter.

Where does backward stop?Choose a training setup, predict the gradients, then trace backward.

This is a schematic of the CPU lab’s first backward pass, not an autograd engine or memory measurement. In LoRA modes the head is frozen and adapters start with random A and zero B. In head mode the adapters are absent and only the head is trainable. “Crossed” means backward computes through that stage; frozen weights still receive no parameter gradient. Text labels accompany every color.

2A · A silent implementation bug

In a network with early and late adapters, someone wraps the frozen layer between them in no_grad(). Loss still decreases. Does that prove both adapters are learning? What would you inspect?

Predict, then reveal

No. The late adapter can learn while the early adapter is disconnected. After backward, inspect every intended trainable parameter’s .grad, then compare weights before and after an optimizer step. A missing gradient differs from a zero gradient: the early factor A can legitimately have a zero first-step gradient under standard LoRA initialization (§3). The lab reproduces this exact bug.

3A small update with a precise budget

3.1 Count the update, not the base matrix

For $W_0\in\mathbb R^{d_{\mathrm{out}}\times d_{\mathrm{in}}}$, LoRA uses

$$h=W_0x+sB(Ax),\quad A\in\mathbb R^{r\times d_{\mathrm{in}}},\quad B\in\mathbb R^{d_{\mathrm{out}}\times r},\quad s=\alpha/r.$$

The trainable update has $r(d_{\mathrm{in}}+d_{\mathrm{out}})$ parameters and rank at most $r$. The base matrix remains stored and frozen. It need not be low rank: the constraint is on the update. During training, compute $A x$ then $B(Ax)$ without materializing the dense product $BA$. This is the formulation in Hu et al., LoRA.

For $d=4096$ and $r=8$, count the adapter parameters on one square projection. Extend this to Q/K/V/O in all 32 layers. What fraction of the corresponding dense projection parameters does this train?

Predict, then reveal

One adapter: $2dr=65{,}536$ versus $d^2=16{,}777{,}216$ dense parameters, a ratio $2r/d=1/256\approx0.391\%$. Across all $32\times4$ projections: $8{,}388{,}608$ adapter parameters. This percentage is relative to the targeted projections, not to all model parameters.

For the slide’s Llama-2-style example, $L=32$, $d=4096$, and $V=32{,}000$, a bias-free generation head has $dV=131{,}072{,}000$ trainable parameters. Q/K/V/O LoRA has $8Ldr=1{,}048{,}576r$.

ConfigurationTrainable parameters
Head only131,072,000
Q/K/V/O LoRA, rank 11,048,576
Q/K/V/O LoRA, rank 44,194,304
Q/K/V/O LoRA, rank 1616,777,216

At rank 125 these parameter counts are equal; this is a parameter-count crossover, not a total-memory crossover. These counts assume four square attention projections. Grouped-query attention can make K/V rectangular; use their actual shapes. For each of three gated-FFN projections with intermediate width $m$, the adapter count is $r(d+m)$.

3B · “Head” can mean very different thingsAdapted from Spring 2025 HW6 §3.2(b); generation comparison added

Ignore biases. Compare a binary classification head with a generation head when $d=4096$ and $V=32{,}000$. Is low-rank LoRA necessarily smaller than either one?

Predict, then reveal

The classifier has $2d=8192$ parameters; the generation head has $dV=131{,}072{,}000$. Rank-16 Q/K/V/O LoRA has 16,777,216: much larger than this classifier and smaller than this generation head. Specify the task and head before making an efficiency claim.

3.2 Why initialize only one factor to zero?

Standard LoRA initializes $A$ randomly and $B=0$, so the adapter initially contributes zero. For one input and upstream gradient $g$,

$$\nabla_B\mathcal L=s\,g(Ax)^\top,\qquad \nabla_A\mathcal L=s\,B^\top g x^\top.$$

Initially, the task gradient for $A$ is zero, but the gradient for $B$ can be nonzero. After $B$ changes, $A$ can receive a task gradient. If both factors start at zero, both task gradients are zero. This is a bilinear dead start, not a reason to initialize the entire adapter randomly and lose the base-model starting behavior.

3C · Do the first update by hand

Use scalars $x=1$, $W_0=1$, $A=2$, $B=0$, $s=1$, and $\mathcal L=\tfrac{1}{2}(h-3)^2$, where $h=W_0x+BAx$. With SGD learning rate 0.1 and no decay, find both factor gradients and the new output.

Predict, then reveal

Initially $h=1$, so $g=-2$. Then $\nabla_B=-4$ and $\nabla_A=0$. The simultaneous update gives $B=0.4$, $A=2$, and new $h=1.8$. Starting with both factors zero would leave both unchanged. Weight decay is excluded here; it can change a nonzero factor even when its task gradient is zero.

3.3 Rank and placement buy different things

A higher rank expands the possible update at each targeted projection. More target projections let updates enter at more points in the computation. At fixed adapter count, these choices compete: Q/V at rank 16 and Q/K/V/O at rank 8 cost the same under square-projection assumptions. Equal parameter budgets do not imply equal learned functions or runtime. Compare them on validation data.

Scaling $s$ matters too. Changing rank with fixed $\alpha$ also changes $\alpha/r$. Record rank, target modules, scaling convention, dropout, and learning rate together. Prefer the smallest configuration meeting the task requirement rather than assuming the largest fitting rank must generalize best.

4Budget training memory and computation separately

4.1 Fewer trainable parameters is only part of the story

Use four accounts: model weights, gradients, optimizer state, and activations retained for backward. Add temporary buffers and implementation overhead when measuring a real run. Low-rank adapters reduce parameter-related memory compared with full tuning; they do not remove the dense base weights or all activation storage.

MethodParameter gradients / Adam stateBackward activations
Full tuningAll trainable model parametersNeeded through the model
Head tuningHead onlyRetain head inputs; no internal frozen-backbone history under §2 assumptions
LoRAAdapter parameters only, unless other parameters are explicitly trainedBackward traverses layers between adapters and the loss

Increasing micro-batch size or sequence length can make activation memory dominate. Checkpointing trades recomputation for less activation storage. Gradient accumulation changes the effective batch without requiring all micro-batch graphs to remain live. Therefore a smaller adapter checkpoint does not establish a smaller peak training allocation.

Can the memory ranking reverse?First predict; then change the assumed activation budgets.

Use the slide’s $d=4096$, $L=32$, $V=32{,}000$ and an approximate 7B-parameter base. The activation budgets below are hypothetical inputs, not estimates derived from rank or sequence length.

Accounting assumptions: two bytes per base/adapter weight; four bytes per gradient and eight bytes for two Adam moments per trainable parameter. The head’s weights are already included in the base; adapter weights are additional. No master-weight copy, logits/loss buffers, workspaces, communication, allocator overhead, sharding, or quantization is modeled. FFN width is 11,008. GiB = 2³⁰ bytes. Changing targets does not automatically update the assumed activation budgets.

4.2 A reversal you can calculate without the widget

At rank 16 with Q/K/V/O targets, the head needs 1.465 GiB for gradients and Adam moments. LoRA needs 0.219 GiB for adapter weights, gradients, and moments. Both share about 13.039 GiB of base weights under the widget’s assumptions.

4A · Which fits?

Keep head activations at 0.25 GiB. First give LoRA 0.5 GiB of activations; then give it 4 GiB. Calculate both accounted totals and identify the winner. Does the adapter count change?

Predict, then reveal

Using unrounded component values, head: $13.039+1.465+0.25\approx14.753$ GiB. LoRA: $13.039+0.219+0.5\approx13.757$ GiB in the first case, and $17.257$ GiB in the second. The ranking reverses with no parameter-count change. Equality occurs at about 1.496 GiB of LoRA activations with the head fixed at 0.25 GiB. These sums explain a mechanism; they are not predictions of a particular GPU’s measured peak.

4.3 What a frozen matrix still computes

For $n$ token rows and a dense $d\times d$ projection, using two FLOPs per multiply–add, the matrix products cost approximately:

ProductFull tuningFrozen base inside LoRA
Forward: $XW^\top$$2nd^2$$2nd^2$
Input gradient: $GW$$2nd^2$ when needed$2nd^2$ when needed to reach earlier adapters
Weight gradient: $G^\top X$$2nd^2$Skipped for frozen $W$

The LoRA branch adds approximately $4ndr$ forward FLOPs for a square matrix, plus its backward work. Thus freezing the dense base can save weight-gradient computation, while retaining its forward and input-gradient work. This isolated projection is not an end-to-end Transformer speed model: attention, activations, optimizer work, checkpointing, and kernels also matter. Measure tokens/second and validation quality at a stated budget.

Training and deployment use different computationsDuring training use $W_0x+sB(Ax)$. For one deployed adapter, merge $W_{\mathrm{merged}}=W_0+sBA$ once, then use the original dense projection shape. That removes the extra adapter branch but does not turn the base model into a smaller dense model. The original paper’s method section distinguishes the training branch and inference merge.

5Run an experiment that answers your question

5.1 Define success before selecting a method

For support-ticket extraction, report valid-JSON rate, correct field values, and exact match of parsed records. Compare against the same base model with a fixed prompt. Hold out paraphrases and split related messages by customer/thread or source so near-duplicates do not leak across splits. Use validation for rank, learning rate, and checkpoint selection; reserve the test set for the final comparison.

Also check a small set of general capabilities you want to preserve. Better target-task learning can accompany forgetting elsewhere. Biderman et al., LoRA Learns Less and Forgets Less studies this tradeoff on code and math; its results motivate measuring both, rather than assuming LoRA always matches full tuning or always preserves every capability.

5.2 Equal steps does not mean equal resources

Choose a comparison that matches your constraint: equal processed tokens for sample efficiency, equal training time for a time budget, or the best validation quality that fits one GPU. State which you use. Tune learning rates separately for full tuning and LoRA; a copied full-tuning rate can make a good adapter configuration look weak. “LoRA needs exactly 10× the learning rate” and “higher rank is always better” are not universal rules.

5A · Select a checkpoint, not a story

These are synthetic validation results after comparable training budgets. Which run would you deploy if parsed-record accuracy must be at least 89%, retained general-task accuracy at least 70%, and peak GPU allocation at most 20 GiB?

RunRecord accuracyGeneral accuracyPeak GiB
Full tuning92%66%28
Head tuning85%73%16
LoRA r=889%72%18
LoRA r=6490%69%21
Predict, then reveal

Rank-8 LoRA is the only listed run satisfying all three requirements. The example does not establish that rank 8 is generally optimal. Freeze the selected configuration, then evaluate it once on the held-out test set; validation percentages are not a final unbiased performance estimate.

6Core practice: diagnose before scaling up

6A · Can a head recover a lost distinction?

Two toy input vectors are $x_1=(1,1)^\top$ and $x_2=(1,-1)^\top$, but they need different labels. The frozen backbone projects them with $W_0=[1\;0]$. Calculate both features. Can any head that sees only those features distinguish the inputs? Now apply the rank-one update $\Delta W=[0\;1]$ to that projection. Calculate the new features and give a separating threshold.

Predict, then reveal

Both original features equal 1, so any head depending only on that scalar produces identical outputs. The adapted projection is $[1\;1]$, giving features 2 and 0. A threshold at 1 separates them. This update changes the representation where the distinguishing second coordinate is still available. Putting an adapter after the original scalar projection cannot recover that discarded coordinate. A rank-one update can therefore make a distinction that head tuning cannot, even with a much larger head.

6B · Is a merged adapter a compressed model?Adapted from Spring 2025 Quiz 3, Q3.1.5

You merge a rank-8 adapter into $W_0$ for deployment. Must the dense projection now use fewer inference FLOPs? What changes if adapters remain separate?

Predict, then reveal

No. The merged matrix has the same dimensions as $W_0$, so its dense projection has the same arithmetic shape. Separate adapters add the small low-rank branch but permit sharing the unchanged base among tasks. Merging saves branch overhead, not the base projection.

6C · Three bugs in one setup
model.eval()                         # “freeze the base”
optimizer = AdamW(model.parameters())
# Insert trainable adapters here, AFTER creating the optimizer.
with torch.no_grad():
    logits = model(input_ids)
loss = cross_entropy(logits, targets)
loss.backward()

Identify three independent reasons this is not a valid adapter-training setup.

Predict, then reveal

eval() does not freeze base weights. Adapters inserted after optimizer construction are absent from its parameter groups. Wrapping the whole forward in no_grad() removes the loss’s gradient path, so backward cannot train those adapters. Insert adapters, set and audit trainability, then build the optimizer over intended trainable parameters; keep the required graph enabled. Separately follow the model’s target-shifting and loss-mask contract.

6D · Why did the adapter checkpoint fail elsewhere?

An adapter checkpoint loads into a model with compatible tensor shapes, but outputs are wrong. Name three pieces of metadata you would check beyond the rank.

Predict, then reveal

Check the exact base checkpoint revision, tokenizer/chat template and vocabulary, targeted module names, adapter scaling convention, and any separately trained modules such as a head. Shape compatibility alone does not imply functional compatibility. Save this metadata alongside the adapter and verify a fresh-load output against a fixed input.

7Optional implementation lab and real-code reading

7.1 Run six small checks on CPU

Download 17_adaptation_checks.py and run python3 17_adaptation_checks.py in an environment with PyTorch. No model, dataset, or GPU download is needed. The checks were run with PyTorch 2.12.0 on CPU, using float64 for numerical comparisons. Random tiny networks test implementation mechanics; they are not pretrained LMs and do not compare adaptation quality.

  1. check_response_loss: exactly three response targets, including EOS, receive loss; uniform logits give $\log 8\approx2.079442$.
  2. check_initialization_and_merge: the adapter starts as a no-op, only B has a nonzero first task gradient, the frozen base stays unchanged, and merged outputs match.
  3. check_frozen_bridge: both adapters receive gradients through a frozen intermediate layer; a deliberate no_grad() bug blocks only the early adapter.
  4. check_head_only: frozen-backbone features have no gradient history while the separate head receives gradients.
  5. check_counts: verify the model-wide parameter counts in §3.
  6. check_existing_layer: wrap an existing biased linear layer, preserve its initial output, update only the adapter, and verify that the base weight and bias stay unchanged.

The lab’s original teaching module is short enough to inspect completely:

class LoRALinear(nn.Module):
    """Teaching implementation; optional existing base, no adapter dropout/auto-merge."""
    def __init__(self, din, dout, r=2, alpha=8, *, base=None):
        super().__init__()
        if r <= 0:
            raise ValueError("rank must be positive")
        self.base = nn.Linear(din, dout, bias=False) if base is None else base
        self.base.requires_grad_(False)
        weight = self.base.weight
        self.A = nn.Parameter(torch.randn(r, din, device=weight.device,
                                         dtype=weight.dtype) * 0.1)
        self.B = nn.Parameter(weight.new_zeros(dout, r))
        self.scale = alpha / r

    @classmethod
    def from_linear(cls, base, r=2, alpha=8):
        # Reuse the exact layer, including its bias, dtype, device, and weights.
        return cls(base.in_features, base.out_features, r, alpha, base=base)

    def forward(self, x):
        return self.base(x) + self.scale * F.linear(F.linear(x, self.A), self.B)

To adapt an existing model, reuse its actual layer instead of constructing a fresh random base. For example, replacing a model’s projection can look like this:

model.requires_grad_(False)  # Freeze before injecting adapters.
original = model.proj  # An existing nn.Linear from the loaded model.
x = example_features  # Inputs to this projection, not raw token IDs.
with torch.no_grad():
    expected = original(x).clone()
model.proj = LoRALinear.from_linear(original, r=2, alpha=8)
torch.testing.assert_close(model.proj(x), expected, rtol=0, atol=0)
optimizer = torch.optim.AdamW(
    [p for p in model.parameters() if p.requires_grad], lr=1e-4)

This replacement retains the exact base weight, bias, dtype, and device, and freezes that layer’s base parameters. It does not freeze the rest of the model. For adapter-only training, freeze the full model before replacing the desired projections; then audit all trainable names before creating the optimizer. The lab’s check_existing_layer checks exact initial output parity, a nonzero adapter update, and unchanged base weights and bias.

The nested F.linear calls apply A and then B. PyTorch stores linear weights as [out_features, in_features]; the math uses column vectors while the implementation accepts batches of row vectors. The frozen base call remains outside no_grad() so the module works correctly when its input depends on an earlier adapter.

7A · Mutate, run, repair
  1. In the merge check, replace + layer.scale * (layer.B @ layer.A) with - layer.scale * (layer.B @ layer.A). Predict which assertion fails, run it, then repair it.
  2. In TinyAdapterChain.forward, wrap the bridge in no_grad() for both branches. Predict which adapter loses its gradient and rerun.
  3. Mask the EOS target in check_response_loss. Change the expected target count to two and revise the EOS gradient assertion. Does the initial loss stay at $\log8$?
Predict, then reveal

The merge output-parity assertion fails after the nonzero B update; the correct run’s max absolute error is below $10^{-12}$. The bridge mutation makes good.early.B.grad missing, while the late B still has a gradient. With EOS masked there are two targets; its prediction-position gradient is zero, but the uniform-logit mean remains $\log8$. An unchanged scalar loss does not prove the same supervision.

7.2 Read the original LoRA linear layer

Pinned implementationOpen microsoft/LoRA · loralib/layers.py, revision c4593f060e6a368d7bb5af5273b8e42810cdef90. Focus on Linear, lines 90–152, rather than the whole file. This is the original library, not a claim about every PEFT implementation. The excerpts below are verbatim and retain the MIT license.

7.3 Find registration, freezing, and scaling

loralib/layers.py · Linear.__init__ · lines 109–114 (verbatim)

        if r > 0:
            self.lora_A = nn.Parameter(self.weight.new_zeros((r, in_features)))
            self.lora_B = nn.Parameter(self.weight.new_zeros((out_features, r)))
            self.scaling = self.lora_alpha / self.r
            # Freezing the pre-trained weight matrix
            self.weight.requires_grad = False

nn.Parameter registers A and B so the module exposes them to optimizers (§2). The shape calculation is exactly §3.1. Check that the optimizer is constructed after injection. Inspect the surrounding constructor: freezing weight here does not by itself freeze a possible bias. A whole-model training policy must decide which biases and other modules to train.

7.4 Locate the two forward branches

loralib/layers.py · Linear.forward · lines 147–152 (verbatim)

        if self.r > 0 and not self.merged:
            result = F.linear(x, T(self.weight), bias=self.bias)            
            result += (self.lora_dropout(x) @ self.lora_A.transpose(0, 1) @ self.lora_B.transpose(0, 1)) * self.scaling
            return result
        else:
            return F.linear(x, T(self.weight), bias=self.bias)

When unmerged, the first call computes the base projection and the next line adds the low-rank path; this is §4.3 in code. Inspect lines 119–125: the executable code initializes A randomly and B to zero, despite a nearby comment describing them in reverse. Read what the operations do.

Read Linear.train, lines 127–142. In this particular library, switching to evaluation can merge the adapter into the base weights, and switching back subtracts it again. This is additional library-specific behavior, beyond ordinary eval() semantics. Do not add the low-rank branch a second time after merging.

7B · Edit the upstream file

In your own copy of pinned layers.py, change nn.init.zeros_(self.lora_B) in Linear.reset_parameters to nn.init.ones_(self.lora_B). Instantiate Linear(4, 3, r=2, lora_alpha=8, bias=False, merge_weights=False) after torch.set_default_dtype(torch.float64) and torch.manual_seed(17), with input torch.tensor([[1., 2., -1., 0.5]]). Compare its output with F.linear(x, layer.weight). Does initialization still preserve the base output? Restore the line, then repeat after calling eval() with merging enabled.

Predict, then reveal

The ones mutation loses no-op initialization. In the checked float64 run, the maximum output difference changes from 0 to approximately 4.386213. Repairing B to zero restores equality. For a meaningful merge check, then set a nonzero B, compare unmerged and merged outputs with dropout disabled, and verify that repeated eval() calls do not merge twice. Before changing initialization, predict A and B task-gradient behavior from §3.2.

8Bonus: one shared base, many tasks

Slide 18’s serving benefit follows from the same decomposition: requests share the base projection but can select different adapter branches. Keeping adapters separate supports switching and batching tasks; merging one adapter removes its branch overhead for a dedicated model. Those are different deployment choices.

Use the approximate 7B base, rank-16 Q/K/V/O adapters from §3, and two bytes per stored weight. Compare 20 full checkpoints with one base plus 20 adapter checkpoints. Ignore metadata and all training state.

Predict, then reveal

Full checkpoints: $20\times7\times10^9\times2=280$ GB. Shared base plus adapters: $14+20\times16{,}777{,}216\times2/10^9\approx14.671$ GB. These are decimal GB of checkpoint weights, not GPU-memory requirements or a claim that every server can batch all configurations efficiently.

Optional sources: LoRA for the factorization and merge; LoRA Learns Less and Forgets Less for controlled adaptation/retention experiments; Punica for multi-tenant serving; PEFT LoRA documentation for a broader implementation API. These extend the reading; model-specific recipes are not required memorization.