Phase 18 / Post-training: SFT, LoRA, reward models and RLHF
LoRA: adapt, save and merge low-rank updates
Revision v0.7.0 · Updated since your last visit. Review the changes; your progress is saved.
Lesson · about 105 minutes · CPU practice
Separate the frozen base from trainable adapters. Understand initialization and the first gradient.
Phase study guide: preparation, milestones and project evidence
Before you start
Before this: Supervised fine-tuning with response-only token loss
Hardware: CPU
Setup help: Python, notebook, or Cloud TPU VM
Use the tested CPU packages (the recorded environment uses Python 3.14.3). Other Python versions and accelerator environments require separate checks. The optional Cloud TPU specialization explains the target-specific execution boundaries. Logical CPU devices practice partitioning; they do not simulate TPU performance.
Download the repository ZIP or use your existing checkout. Extract it and open a terminal in the folder containing requirements-cpu.txt. The beginner ZIP is separate; these lessons use the full repository folder.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-cpu.txtpy -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements-cpu.txtAfter activation, run the complete worked example from that same folder:
python phases/18-posttraining/02-lora/code/main.py.\.venv\Scripts\python.exe phases/18-posttraining/02-lora/code/main.pyFor an incremental build, create main.py in your own practice folder, append each build block in order, and run python main.py with this environment active. Keep that file separate from the downloaded reference solution.
Download this lesson’s notebook. Open it with a Python kernel using the course environment. Run cells from the top after restarting the kernel; experiments depend on earlier definitions.
For local Jupyter, first install the course requirements using the Local Python steps, then use your environment’s Python to start Jupyter:
python -m pip install jupyterlab
python -m jupyter lab.\.venv\Scripts\python.exe -m pip install jupyterlab
.\.venv\Scripts\python.exe -m jupyter labSelect the downloaded posttraining-02.ipynb file in JupyterLab. The notebook contains reference solutions; make your prediction and attempt each practice task before opening them. Restart the kernel before changing logical device setup.
The worked reference for this lesson is CPU-validated. Some references explicitly configure logical CPU devices; setting JAX_PLATFORMS does not adapt them to a TPU. Follow the execution matrix and reviewed multi-host example before attempting a target run.
For accelerator preparation, study TPU setup, observed runtime receipts, checkpointed jobs and precision and retained traces. These target paths remain unqualified on physical TPU hardware until an actual receipt is recorded. Cloud provisioning is optional for CPU learning.
Check: The trained base stays unchanged; 10 adapter scalars fit the rank-one change and merge equivalently.
Revisit the guided CPU setup · Open 19: TPU Setup. Package instructions are CPU instructions; Windows and accelerator environments need their own validation.
What you will be able to do
- Separate the frozen base from trainable adapters
- Understand initialization and the first gradient
- Verify the changed-condition exercise and explain the limits of this fixture.
The problem
A trained dense layer already works, but the task changes. Can we fit the change while leaving the original weights untouched? We will first train a base, then learn and audit a low-rank update.
The idea
LoRA adds a trainable low-rank correction to a frozen weight map. The base still participates in the forward pass while a smaller set of factors adapts the output. Shape convention and scaling determine how those paths combine.
Separate the frozen path from the low-rank correction
Here the base matrix has shape . With rank , factors have shapes and , so their product has the same shape as the base. The correction is scaled by before addition.
In the fixture, the second factor starts at zero. The correction is initially zero; the first factor's initial gradient is also zero, while the second factor can receive a nonzero gradient. This is an informative initialization check rather than evidence that training is broken.
After training, compare separate-branch inference with merged weights using the same scaling. Freezing parameters does not mean their computation vanishes. The plot of factor gradients explains which path learns first; it does not imply every full-model adapter behaves identically.
Frozen base plus a low-rank correction
Predict: If rank changes while alpha stays fixed, can you omit the scaling when merging?
Input enters both branches. The base weight is frozen; A and B are trainable. Both paths return four features before addition. Include when merging. These are forward-data arrows: gradients update adapter factors but not frozen base parameters.
Before coding: adapt a change, not an entire matrix
Begin with a base mapping that already works on a source task. The new task changes the output in a small number of directions. LoRA represents an added matrix through two smaller factors while preserving the base weights. Freezing the base protects those stored weights; it does not guarantee the adapted outputs retain the old behavior. That is why adaptation and retention need separate evaluation.
Trace dimensions for input width , output width , and rank . The first factor compresses inputs into coordinates, and the second expands them into the output coordinates. The update matrix can have rank at most , regardless of training time.
Separate the frozen base from trainable adapters
For input-by-output weights , let have shape and shape . Compute the base output plus the scaled adapter output. Only the adapter tuple is passed to the gradient function. Keep a copy of and compare it exactly after training.
Understand initialization and the first gradient
Initialize randomly and to zero. The initial adapter output is zero, so predictions match the base. At this first step the gradient with respect to is zero because it multiplies ; the gradient for can be nonzero. Initializing both factors to zero would block both gradients for this bilinear update.
Derive the first-step asymmetry
Write the dense-layer output gradient as , with the same batch-by-output shape as the prediction. Let . The two derivatives below explain initialization. When , the first derivative vanishes, while the second can be nonzero if is random. After one update to , both factors may learn. If both factors start at zero, both gradients vanish and training stalls.
This is a property of the bilinear parameterization, not a JAX bug. Inspect factor norms and individual gradients before changing the learning rate.
Use rank to predict a failure before training
A rank-one update can represent one independent output-change direction. Consider an ideal update with singular values and . Any rank-one approximation leaves at least squared Frobenius error ; increasing optimization steps cannot remove that rank constraint. This matrix-space statement translates into prediction error only under assumptions about the input distribution. The exercise uses identity inputs so that distinction is visible.
Our main target was constructed as a rank-one update, so near-zero training error is expected. A convincing study compares several ranks on a harder held-out task and reports trainable parameters, optimizer memory, activation memory and measured runtime separately.
Count the right kind of savings
The fixture has base weights and rank , giving trainable adapter scalars. The base remains resident. Real memory includes gradients, optimizer state, activations and runtime buffers. A low-bit frozen base plus adapters also needs a quantization policy and supported kernels; this lab does not implement QLoRA.
Merge only under an explicit serving contract
Without adapter dropout, an inference merge computes . Check outputs on changed inputs before deleting or replacing anything. Keep the unmerged base, adapter and scaling metadata; a second accidental merge adds the update twice. Packed or quantized bases require dequantization/requantization rules rather than a blind in-place add.
Save the adaptation identity
An adapter artifact needs the base checkpoint hash, target module names, rank, alpha, dtype and exact factor arrays. Match all of these on load. Compare the adapted task and original task separately; low rank does not prevent forgetting, and this rank-one teacher does not predict the rank required by a real task.
Make an adapter artifact reproducible
Save the exact base identity, target layer names, tensor orientation, rank, scaling, factor dtype and adapter weights. Reconstruct the unmerged computation before testing a merge. A rank-two adapter with requires scale ; forgetting that scale may go unnoticed in rank-one examples where it equals one.
Merging is exact algebraically for this dense layer, subject to floating-point rounding. Quantized bases, packed layouts and adapter dropout need additional contracts. Retain original artifacts, compare changed inputs after reload, and reject applying the same update twice. Keep a source-task metric alongside the new task before claiming that adaptation preserved useful behavior.
LoRA: adapt, save and merge low-rank updates — recorded experiment
Predict before looking: Which initial adapter-gradient bar is zero, and why can training still begin?
Read the figure
The plot is adaptation MSE before each update, falling from about to below . It does not include the preceding base-training run. The target update was constructed to have rank one, so this is an achievable representation test rather than a universal LoRA performance claim.
The second panel compares the two initial factor-gradient norms. The zero-height A bar is expected because B starts at zero; the positive B bar shows the update has a route to begin learning. After B changes, A can receive gradient. These are initial gradients while the first panel spans the full adaptation run. The all-zero initialization experiment predicts two zero bars and nonzero loss, exposing a stalled parameterization.
Connect it to the computation
The first adapter update affects B; later updates can train both factors. The unchanged-base assertion, reloaded adapter and new-input merge comparison are separate evidence from the falling training line.
Open full-size figureDownload data and provenance
The notebook contains the data experiment, plotting code, and recorded figure output. Rerun it to test your changes.
Where to put & run this code: Work in phases/18-posttraining/02-lora/code/main.py (or downloaded exercises/posttraining-02.py / exercises/posttraining-02.ipynb) and execute from the repository root with python3 phases/18-posttraining/02-lora/code/main.py.
1 Define the frozen-plus-adapter forward pass
Create main.py in your activated course environment. Paste this block, then run python main.py; function definitions alone print nothing.
# Step 1 — 1. Define the frozen-plus-adapter forward pass: The base is an input to the forward pass but is not in the adapter...
import jax
import jax.numpy as jnp
import numpy as np
def lora_forward(x, base, a, b, alpha=1.0):
rank = a.shape[1]
return x @ base + (alpha / rank) * (x @ a @ b)Code building blocks in this step
lora_forward(...)— Calllora_forwardwith your updated parameters or inputs from this lesson's workspace.
The base is an input to the forward pass but is not in the adapter tuple differentiated by loss.
2 Train a base, freeze it and initialize the adapter
Append this block to main.py and run python main.py again. Keep the earlier blocks above it.
# Step 2 — 2. Train a base, freeze it and initialize the adapter: This step includes the source-task training.
x = jnp.asarray(np.random.default_rng(4).normal(size=(24, 6)), jnp.float32)
# Construct and reshape `source_w` into the target tensor dimensions.
source_w = jnp.arange(24, dtype=jnp.float32).reshape(6, 4) / 40
base = jnp.zeros_like(source_w)
# Differentiate the objective to obtain `base_step` via automatic differentiation.
base_step = jax.jit(jax.grad(lambda w: jnp.mean((x @ w - x @ source_w) ** 2)))
for _ in range(150):
base = base - 0.15 * base_step(base)
assert float(jnp.mean((x @ base - x @ source_w) ** 2)) < 1e-4
# Convert `frozen` to a host NumPy array for inspection or verification.
frozen = np.asarray(base).copy()
u = jnp.array([[0.2], [-0.3], [0.1], [0.2], [-0.1], [0.4]])
v = jnp.array([[0.5, -0.2, 0.3, 0.1]])
# Perform matrix / vector contraction (`@`) to compute `target`.
target = x @ (base + u @ v)
# Initialize explicit deterministic PRNG key `a`.
a = jax.random.normal(jax.random.key(4), (6, 1)) * 0.1
# Allocate initialized array `b` with the specified shape and dtype.
b = jnp.zeros((1, 4))
params = (a, b)
loss = lambda ab: jnp.mean((lora_forward(x, base, *ab, alpha=1.0) - target) ** 2)
# Evaluate both scalar loss and parameter gradients in one pass (`step`).
step = jax.jit(jax.value_and_grad(loss))
history = []
initial_grads = step(params)[1]
# Allocate initialized array `` with the specified shape and dtype.
np.testing.assert_array_equal(initial_grads[0], np.zeros((6, 1)))
assert np.linalg.norm(initial_grads[1]) > 0Code building blocks in this step
jnp.arange(n, dtype=...)— Creates a 1-D JAX array of evenly spaced values[0, 1, ..., n-1]on the target device.jnp.array(values, dtype=...)— Constructs an immutable device-backed JAX array from Python/NumPy values.jnp.zeros / jnp.ones(shape, dtype=...)— Allocates a tensor of the givenshapeinitialized with constants.array.reshape(new_shape)— Reorganizes tensor axes without changing the total element count (array.size).
This step includes the source-task training. Inspect the initial factor gradients: A has zero gradient and B has a nonzero one.
3 Adapt and verify the serving merge
Append this block to main.py and run python main.py again. Keep the earlier blocks above it.
# Step 3 — 3. Adapt and verify the serving merge: The reference target has rank one by construction.
for _ in range(250):
value, g = step(params)
history.append(float(value))
# Transform every leaf of the parameter PyTree (`params`).
params = jax.tree.map(lambda a, b: a - 0.4 * b, params, g)
a, b = params
assert history[-1] < history[0] * 0.02
# Execute `np.testing.assert_array_equal(base, frozen)`
np.testing.assert_array_equal(base, frozen)
merged = base + a @ b
np.testing.assert_allclose(
lora_forward(x, base, a, b), x @ merged, rtol=1e-5, atol=1e-6
)
print(
'LoRA initial/final:',
history[0],
history[-1],
'; trainable:',
a.size + b.size,
'base:',
base.size,
)Code building blocks in this step
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.jax.tree.map(lambda p, g: ..., params, grads)— Applies a function leaf-by-leaf across matching PyTrees (such as updating every parameter tensor with its gradient).
The reference target has rank one by construction. Exact base equality and changed-input merge checks answer different questions from adaptation loss.
View the cumulative build in main.py
These blocks append in order to the same file.
# Step 1 — 1. Define the frozen-plus-adapter forward pass: The base is an input to the forward pass but is not in the adapter...
import jax
import jax.numpy as jnp
import numpy as np
def lora_forward(x, base, a, b, alpha=1.0):
rank = a.shape[1]
return x @ base + (alpha / rank) * (x @ a @ b)
# Step 2 — 2. Train a base, freeze it and initialize the adapter: This step includes the source-task training.
x = jnp.asarray(np.random.default_rng(4).normal(size=(24, 6)), jnp.float32)
# Construct and reshape `source_w` into the target tensor dimensions.
source_w = jnp.arange(24, dtype=jnp.float32).reshape(6, 4) / 40
base = jnp.zeros_like(source_w)
# Differentiate the objective to obtain `base_step` via automatic differentiation.
base_step = jax.jit(jax.grad(lambda w: jnp.mean((x @ w - x @ source_w) ** 2)))
for _ in range(150):
base = base - 0.15 * base_step(base)
assert float(jnp.mean((x @ base - x @ source_w) ** 2)) < 1e-4
# Convert `frozen` to a host NumPy array for inspection or verification.
frozen = np.asarray(base).copy()
u = jnp.array([[0.2], [-0.3], [0.1], [0.2], [-0.1], [0.4]])
v = jnp.array([[0.5, -0.2, 0.3, 0.1]])
# Perform matrix / vector contraction (`@`) to compute `target`.
target = x @ (base + u @ v)
# Initialize explicit deterministic PRNG key `a`.
a = jax.random.normal(jax.random.key(4), (6, 1)) * 0.1
# Allocate initialized array `b` with the specified shape and dtype.
b = jnp.zeros((1, 4))
params = (a, b)
loss = lambda ab: jnp.mean((lora_forward(x, base, *ab, alpha=1.0) - target) ** 2)
# Evaluate both scalar loss and parameter gradients in one pass (`step`).
step = jax.jit(jax.value_and_grad(loss))
history = []
initial_grads = step(params)[1]
# Allocate initialized array `` with the specified shape and dtype.
np.testing.assert_array_equal(initial_grads[0], np.zeros((6, 1)))
assert np.linalg.norm(initial_grads[1]) > 0
# Step 3 — 3. Adapt and verify the serving merge: The reference target has rank one by construction.
for _ in range(250):
value, g = step(params)
history.append(float(value))
# Transform every leaf of the parameter PyTree (`params`).
params = jax.tree.map(lambda a, b: a - 0.4 * b, params, g)
a, b = params
assert history[-1] < history[0] * 0.02
# Execute `np.testing.assert_array_equal(base, frozen)`
np.testing.assert_array_equal(base, frozen)
merged = base + a @ b
np.testing.assert_allclose(
lora_forward(x, base, a, b), x @ merged, rtol=1e-5, atol=1e-6
)
print(
'LoRA initial/final:',
history[0],
history[-1],
'; trainable:',
a.size + b.size,
'base:',
base.size,
)Run the example
python3 phases/18-posttraining/02-lora/code/main.pyAbout this command
This shortcut uses an activated macOS/Linux course environment in the repository folder. For Windows, use the explicit interpreter command in “Setup help” above. The script includes worked examples and reference solutions.
Inspect the complete assembled example
# Step 1 — 1. Define the frozen-plus-adapter forward pass: The base is an input to the forward pass but is not in the adapter...
import jax
import jax.numpy as jnp
import numpy as np
def lora_forward(x, base, a, b, alpha=1.0):
rank = a.shape[1]
return x @ base + (alpha / rank) * (x @ a @ b)
# Step 2 — 2. Train a base, freeze it and initialize the adapter: This step includes the source-task training.
x = jnp.asarray(np.random.default_rng(4).normal(size=(24, 6)), jnp.float32)
# Construct and reshape `source_w` into the target tensor dimensions.
source_w = jnp.arange(24, dtype=jnp.float32).reshape(6, 4) / 40
base = jnp.zeros_like(source_w)
# Differentiate the objective to obtain `base_step` via automatic differentiation.
base_step = jax.jit(jax.grad(lambda w: jnp.mean((x @ w - x @ source_w) ** 2)))
for _ in range(150):
base = base - 0.15 * base_step(base)
assert float(jnp.mean((x @ base - x @ source_w) ** 2)) < 1e-4
# Convert `frozen` to a host NumPy array for inspection or verification.
frozen = np.asarray(base).copy()
u = jnp.array([[0.2], [-0.3], [0.1], [0.2], [-0.1], [0.4]])
v = jnp.array([[0.5, -0.2, 0.3, 0.1]])
# Perform matrix / vector contraction (`@`) to compute `target`.
target = x @ (base + u @ v)
# Initialize explicit deterministic PRNG key `a`.
a = jax.random.normal(jax.random.key(4), (6, 1)) * 0.1
# Allocate initialized array `b` with the specified shape and dtype.
b = jnp.zeros((1, 4))
params = (a, b)
loss = lambda ab: jnp.mean((lora_forward(x, base, *ab, alpha=1.0) - target) ** 2)
# Evaluate both scalar loss and parameter gradients in one pass (`step`).
step = jax.jit(jax.value_and_grad(loss))
history = []
initial_grads = step(params)[1]
# Allocate initialized array `` with the specified shape and dtype.
np.testing.assert_array_equal(initial_grads[0], np.zeros((6, 1)))
assert np.linalg.norm(initial_grads[1]) > 0
# Step 3 — 3. Adapt and verify the serving merge: The reference target has rank one by construction.
for _ in range(250):
value, g = step(params)
history.append(float(value))
# Transform every leaf of the parameter PyTree (`params`).
params = jax.tree.map(lambda a, b: a - 0.4 * b, params, g)
a, b = params
assert history[-1] < history[0] * 0.02
# Execute `np.testing.assert_array_equal(base, frozen)`
np.testing.assert_array_equal(base, frozen)
merged = base + a @ b
np.testing.assert_allclose(
lora_forward(x, base, a, b), x @ merged, rtol=1e-5, atol=1e-6
)
print(
'LoRA initial/final:',
history[0],
history[-1],
'; trainable:',
a.size + b.size,
'base:',
base.size,
)Expected result: The trained base stays unchanged; 10 adapter scalars fit the rank-one change and merge equivalently.
Recorded reference execution
Reference output captured from executing the complete lesson script (code/main.py) and its assertions in the course CPU environment.
LoRA initial/final: 0.043869297951459885 2.466217665642034e-07 ; trainable: 10 base: 24
LoRA initial/final: 0.043869297951459885 2.466217665642034e-07 ; trainable: 10 base: 24
Both zero factors produce zero first gradients.
Both LoRA factor gradients match the matrix-calculus oracle at alpha/r = 1.5.
Adapter round trip and merge checked.
New-input merge passes; accidental double merge changes outputs.
Nonunit scaling passes; best rank-one identity-input MSE is 0.25.
PASS: posttraining-02
Download execution record · The notebook includes executed cells and figure outputs. Compare your run with these values; timings depend on the machine.
Show why two zero factors cannot start learning
# Experiment — Show why two zero factors cannot start learning: Each factor’s derivative contains the other factor.
zero = (jnp.zeros_like(a), jnp.zeros_like(b))
# Differentiate the objective to obtain `zero_grad` via automatic differentiation.
zero_grad = jax.grad(loss)(zero)
assert all(
np.array_equal(np.asarray(g), np.zeros(g.shape)) for g in zero_grad
)
print('Both zero factors produce zero first gradients.')Expected: Both factor gradients are exactly zero.
Each factor’s derivative contains the other factor. Randomizing one and zeroing the other preserves the initial base output without blocking the whole update.
Check both factor gradients with matrix calculus
# Experiment — Check both factor gradients with matrix calculus: Nonzero factors exercise both gradient paths, while nonunit...
rng = np.random.default_rng(14)
# Create device-backed JAX array `a_probe`.
a_probe = jnp.asarray(rng.normal(size=(6, 2)) * 0.1, jnp.float32)
# Create device-backed JAX array `b_probe`.
b_probe = jnp.asarray(rng.normal(size=(2, 4)) * 0.1, jnp.float32)
scale = 3.0 / 2
# Evaluate the compound expression for `output_gradient`.
output_gradient = (
2 * (lora_forward(x, base, a_probe, b_probe, 3.0) - target) / target.size
)
# Perform matrix / vector contraction (`@`) to compute `expected_a`.
expected_a = scale * x.T @ output_gradient @ b_probe.T
# Perform matrix / vector contraction (`@`) to compute `expected_b`.
expected_b = scale * a_probe.T @ x.T @ output_gradient
# Differentiate the objective to obtain `(actual_a, actual_b)` via automatic differentiation.
actual_a, actual_b = jax.grad(
lambda aa, bb: jnp.mean(
(lora_forward(x, base, aa, bb, 3.0) - target) ** 2
),
argnums=(0, 1),
)(a_probe, b_probe)
np.testing.assert_allclose(actual_a, expected_a, atol=1e-6)
np.testing.assert_allclose(actual_b, expected_b, atol=1e-6)
print('Both LoRA factor gradients match the matrix-calculus oracle at alpha/r = 1.5.')Expected: Both factor derivatives match the independent output-gradient calculation.
Nonzero factors exercise both gradient paths, while nonunit scaling catches a factor missing from the derivative. The separate all-zero experiment demonstrates the initialization failure.
Make it yours
Where to put your code: Append your solution at the bottom of phases/18-posttraining/02-lora/code/main.py (or inside the Make it yours cell of exercises/posttraining-02.ipynb) and run python3 phases/18-posttraining/02-lora/code/main.py to verify your prediction before opening the reference solution.
Save both adapter factors and their rank/scaling metadata, reload them and check merged versus unmerged predictions.
Show a writing guide and starter scaffold
How to write this exercise code: step-by-step recipe & starter scaffold
1. Key functions & syntax to use:
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.
2. Step-by-step implementation plan:
- Create an isolated temporary directory to run and inspect artifacts safely:
- Convert
to a host NumPy array for inspection or verification. - Enter
np.load(path, allow_pickle=False)context block: - Check tensor shape invariant:
int(z['rank']) == z['a'].shape[1]
# Exercise solution: Save both adapter factors and their rank/scaling metadata, reload them...
import tempfile
from pathlib import Path
# Create an isolated temporary directory to run and inspect artifacts safely:
with tempfile.TemporaryDirectory() as folder:
path = Path(...) # TODO: compute path
# Convert `` to a host NumPy array for inspection or verification.
np.savez(
path,
a = np.asarray(...) # TODO: compute a
b = np.asarray(...) # TODO: compute b
alpha = np.array(...) # TODO: compute alpha
rank = np.array(...) # TODO: compute rank
)
# Enter `np.load(path, allow_pickle=False)` context block:
with np.load(path, allow_pickle=False) as z:
# Check tensor shape invariant: `int(z['rank']) == z['a'].shape[1]`
assert int(z['rank']) # TODO: complete assertion check
np.testing.assert_allclose(
lora_forward(x, base, z['a'], z['b'], float(z['alpha'])),
x @ merged,
atol = ... # TODO: compute atol
rtol = ... # TODO: compute rtol
)
print('Adapter round trip and merge checked.')
Reveal the reference solution
# Exercise solution: Save both adapter factors and their rank/scaling metadata, reload them...
import tempfile
from pathlib import Path
# Create an isolated temporary directory to run and inspect artifacts safely:
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'adapter.npz'
# Convert `` to a host NumPy array for inspection or verification.
np.savez(
path,
a=np.asarray(a),
b=np.asarray(b),
alpha=np.array(1.0),
rank=np.array(1),
)
# Enter `np.load(path, allow_pickle=False)` context block:
with np.load(path, allow_pickle=False) as z:
# Check tensor shape invariant: `int(z['rank']) == z['a'].shape[1]`
assert int(z['rank']) == z['a'].shape[1]
np.testing.assert_allclose(
lora_forward(x, base, z['a'], z['b'], float(z['alpha'])),
x @ merged,
atol=1e-6,
rtol=1e-5,
)
print('Adapter round trip and merge checked.')Check the merge on new inputs
Transfer
Draw a new input batch and compare the merged layer with the adapter path.
Show a writing guide and starter scaffold
How to write this practice solution: step-by-step recipe & starter scaffold
1. Key functions & syntax to use:
jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.
2. Step-by-step implementation plan:
- Set up the changed input or configuration parameters requested in the prompt.
- Run the target function or transformation and assert that the output matches your prediction.
# Check the merge on new inputs (Transfer): The second check detects an operational merge error that...
new_x = jnp.asarray(...) # TODO: compute new_x
np.testing.assert_allclose(
lora_forward(new_x, base, a, b), new_x @ merged, atol=1e-6, rtol=1e-5
)
assert np.linalg.norm(np.asarray(new_x @ (base + 2 * a @ b) - new_x @ merged)) # TODO: complete assertion check
print('New-input merge passes; accidental double merge changes outputs.')
Show a hint
The equivalence is algebraic, so a new input should still agree.
Reveal reference solution and reasoning
# Check the merge on new inputs (Transfer): The second check detects an operational merge error that...
new_x = jnp.asarray(np.random.default_rng(91).normal(size=(7, 6)), jnp.float32)
np.testing.assert_allclose(
lora_forward(new_x, base, a, b), new_x @ merged, atol=1e-6, rtol=1e-5
)
assert np.linalg.norm(np.asarray(new_x @ (base + 2 * a @ b) - new_x @ merged)) > 1e-3
print('New-input merge passes; accidental double merge changes outputs.')The second check detects an operational merge error that training loss alone would not reveal.
Test nonunit scaling and an impossible rank-one target
Challenge
Check merge equivalence at rank two with alpha three. Then use identity inputs and a diagonal target with singular values three and one to calculate the best rank-one residual.
Show a writing guide and starter scaffold
How to write this practice solution: step-by-step recipe & starter scaffold
1. Key functions & syntax to use:
x.sum(axis=...) / x.mean(axis=..., keepdims=...)— Reduces values along the namedaxis(the axis you name is collapsed unlesskeepdims=True).jnp.allclose(actual, expected, rtol=..., atol=...)— Checks that two arrays match elementwise within floating-point tolerance.
2. Step-by-step implementation plan:
- Create device-backed JAX array
a2. - Create device-backed JAX array
b2. - Perform matrix / vector contraction (
@) to computerank_one. - Execute
np.testing.assert_allclose(np.sum((target_update - rank_one) ** 2), 1.0). - Construct an identity matrix
.
# Test nonunit scaling and an impossible rank-one target (Challenge): The residual predicts a capacity limit independently of an...
rng = np.random.default_rng(...) # TODO: compute rng
# Create device-backed JAX array `a2`.
a2 = jnp.asarray(...) # TODO: compute a2
# Create device-backed JAX array `b2`.
b2 = jnp.asarray(...) # TODO: compute b2
np.testing.assert_allclose(
lora_forward(x, base, a2, b2, alpha = ... # TODO: compute lora_forward(x, base, a2, b2, alpha
x @ (base + 1.5 * a2 @ b2),
rtol = ... # TODO: compute rtol
atol = ... # TODO: compute atol
)
target_update = np.diag(...) # TODO: compute target_update
u_s, s_s, v_s = np.linalg.svd(...) # TODO: compute u_s, s_s, v_s
# Perform matrix / vector contraction (`@`) to compute `rank_one`.
rank_one = ... # TODO: compute rank_one
# Execute `np.testing.assert_allclose(np.sum((target_update - rank_one) ** 2), 1.0)`.
np.testing.assert_allclose(np.sum((target_update - rank_one) ** 2), 1.0)
# Construct an identity matrix ``.
np.testing.assert_allclose(
np.mean((np.eye(2) @ target_update - np.eye(2) @ rank_one) ** 2), 0.25
)
print('Nonunit scaling passes; best rank-one identity-input MSE is 0.25.')
Show a hint
Use an independent singular-value decomposition for the rank bound, and divide alpha by rank in both serving paths.
Reveal reference solution and reasoning
# Test nonunit scaling and an impossible rank-one target (Challenge): The residual predicts a capacity limit independently of an...
rng = np.random.default_rng(8)
# Create device-backed JAX array `a2`.
a2 = jnp.asarray(rng.normal(size=(6, 2)), jnp.float32)
# Create device-backed JAX array `b2`.
b2 = jnp.asarray(rng.normal(size=(2, 4)), jnp.float32)
np.testing.assert_allclose(
lora_forward(x, base, a2, b2, alpha=3.0),
x @ (base + 1.5 * a2 @ b2),
rtol=1e-5,
atol=2e-6,
)
target_update = np.diag([3.0, 1.0])
u_s, s_s, v_s = np.linalg.svd(target_update)
# Perform matrix / vector contraction (`@`) to compute `rank_one`.
rank_one = (u_s[:, :1] * s_s[:1]) @ v_s[:1]
# Execute `np.testing.assert_allclose(np.sum((target_update - rank_one) ** 2), 1.0)`.
np.testing.assert_allclose(np.sum((target_update - rank_one) ** 2), 1.0)
# Construct an identity matrix ``.
np.testing.assert_allclose(
np.mean((np.eye(2) @ target_update - np.eye(2) @ rank_one) ** 2), 0.25
)
print('Nonunit scaling passes; best rank-one identity-input MSE is 0.25.')The residual predicts a capacity limit independently of an optimizer. The nonunit-alpha case catches a scaling bug that the main rank-one demonstration would miss.
Diagnose the result
If neither factor moves, inspect initialization. If merged predictions drift, check factor orientation, rank scaling, dropout, base identity and accidental repeated merges.
Carry forward
- Initialize one LoRA factor randomly and the other to zero so the initial correction is zero while the zero-initialized factor still receives a nonzero first gradient.
- Preserve the base checkpoint identity, rank , and scale in saved adapter artifacts, and verify merged weights against unmerged inference on new inputs.
Keep your evidence
Keep base-training evidence, unchanged base arrays, adapter factors/rank/alpha, initial-gradient checks and reloaded/new-input merge parity.
Save your modified code, its output, and an explanation of your prediction. Checkpoint answers and exercise evidence are tracked separately.
Saved in this browser. Export or back up your portfolio anytime in My learning.
Verified on local CPU. Stage to a Cloud TPU VM via Cloud TPU setup to record accelerator execution receipts (TODO: add automated Cloud TPU CI execution receipts).