DEG-5B-E2B-1019 / source /tests /test_lora.py
gcoderw's picture
DEG-5B-E2B-1019 v0.2 (multimodal)
f8dbde1 verified
Raw History Blame Contribute Delete
7.86 kB
"""LoRA adapters on a frozen base backbone (``--lora-rank``), tiny random Qwen3.5 (CPU):
* zero-initialised adapters leave every head output bitwise unchanged;
* with trained (random) adapters, a field's outputs are bitwise the same alone, reordered
and next to fields that run another adapter (per-row selector, batch-invariant);
* separate decide/extract adapters are isolated: changing one never moves the other
capability's outputs;
* x-generate decodes adapter-off: the same text as the two-copy hybrid with the same base;
* training: only the selected adapter of each fork kind gets gradient, the base
backbone none, and with ``context="base"`` the trunk carries no graph at all;
* save -> load round-trips the adapters (outputs bitwise equal).
"""
from __future__ import annotations
import copy
import pytest
torch = pytest.importorskip("torch")
pytest.importorskip("transformers")
from test_model import CONTEXT, SCHEMA, SNAPSHOT, _raw_outputs, _sub, tiny_lm # noqa: E402
from test_train_mem import _examples # noqa: E402
from deg.infer import fill # noqa: E402
from deg.model import add_lora, load_model, load_tokenizer # noqa: E402
FLAT = [n for n in SCHEMA["properties"] if n not in ("items", "reply")]
DECIDE = ["department", "urgent", "anger"]
EXTRACT = ["email", "order_id", "total", "charged_on"]
def _model(adapters=None, context="base", rank=4, randomize=True, lm=None):
if SNAPSHOT is None:
pytest.skip("Qwen3.5-0.8B tokenizer not cached")
m = load_model(None, device="cpu", dtype=torch.float32, lm=lm or tiny_lm(0),
tokenizer=load_tokenizer(SNAPSHOT))
torch.manual_seed(1)
with torch.no_grad():
for p in m.heads.parameters():
p.normal_(0.0, 0.5)
if adapters is not None:
m.backbone.requires_grad_(False)
add_lora(m, rank, None, adapters, context)
if randomize:
g = torch.Generator().manual_seed(3)
with torch.no_grad():
for name, p in m.backbone.named_parameters():
if ".lora_B" in name:
p.copy_(torch.randn(p.shape, generator=g) * 0.05)
return m
def _same(a, b, keys=None):
keys = keys or a.keys()
for k in keys:
assert torch.equal(a[k], b[k]), k
def test_zero_adapters_are_the_base_model():
plain = _model()
zero = _model(["decide", "extract"], randomize=False)
_same(_raw_outputs(plain, _sub(FLAT)), _raw_outputs(zero, _sub(FLAT)))
@pytest.mark.parametrize("adapters,context", [(["shared"], "base"),
(["decide", "extract"], "base"),
(["shared"], "shared")])
def test_adapter_rows_are_batch_invariant(adapters, context):
m = _model(adapters, context)
merged = _raw_outputs(m, _sub(FLAT))
_same(merged, _raw_outputs(m, _sub(list(reversed(FLAT)))))
for names in (DECIDE, EXTRACT, ["anger"], ["total"]):
alone = _raw_outputs(m, _sub(names))
_same(alone, merged, alone.keys())
if adapters == ["decide", "extract"]: # the adapters actually change the outputs
plain = _raw_outputs(_model(), _sub(FLAT))
assert any(not torch.equal(plain[k], merged[k]) for k in merged)
def test_separate_adapters_are_isolated():
m = _model(["decide", "extract"])
before = _raw_outputs(m, _sub(FLAT))
r = m.lora["rank"]
with torch.no_grad(): # perturb only the decide adapter's columns
for name, p in m.backbone.named_parameters():
if ".lora_B" in name:
p[:, :r] += 0.1
after = _raw_outputs(m, _sub(FLAT))
_same(before, after, [f"/{n}" for n in EXTRACT])
assert any(not torch.equal(before[f"/{n}"], after[f"/{n}"]) for n in DECIDE)
def test_generate_is_the_base_model():
schema = _sub(["department", "order_id", "reply"])
schema["properties"]["reply"] = {"type": "string", "x-generate": True, "maxLength": 40}
lm = tiny_lm(0)
base = copy.deepcopy(lm)
for context in ("base", "shared"):
m = _model(["shared"], context, lm=copy.deepcopy(lm))
hyb = load_model(None, device="cpu", dtype=torch.float32, lm=copy.deepcopy(lm),
tokenizer=load_tokenizer(SNAPSHOT), generate="base",
base_lm=copy.deepcopy(base))
got = fill(m, CONTEXT, schema)
want_known = {k: got["value"][k] for k in ("department", "order_id")}
# the hybrid decodes from the same known values with the same base weights
hyb_plan = fill(hyb, CONTEXT, schema)
if {k: hyb_plan["value"][k] for k in want_known} == want_known:
assert got["value"]["reply"] == hyb_plan["value"]["reply"]
if context == "base": # one prefill: no second context read
assert got["usage"]["prompt_tokens"] < hyb_plan["usage"]["prompt_tokens"]
def test_training_grads_follow_the_fork_kind():
from deg.train import forward_losses
m = _model(["decide", "extract"], "base")
exs = _examples(m)
m.backbone.train()
m.heads.train()
r = m.lora["rank"]
try:
hidden, state = m.trunk([exs[0].ctx])
assert not hidden.requires_grad # base context read: no trunk graph
terms, _ = forward_losses(m, exs, 4096, 4)
assert "lm" not in terms # generate forks are not trained under LoRA
terms["decide"].backward()
finally:
m.backbone.eval()
m.heads.eval()
got = {n: p.grad for n, p in m.backbone.named_parameters() if ".lora_B" in n}
assert all(not p.requires_grad for n, p in m.backbone.named_parameters()
if ".lora_" not in n)
assert any(float(g[:, :r].abs().sum()) > 0 for g in got.values() if g is not None)
assert all(g is None or float(g[:, r:].abs().sum()) == 0 for g in got.values())
def test_save_load_roundtrip(tmp_path):
"""Adapters (saved fp32) come back exactly; the backbone file is bf16, so outputs are
compared between two loads (deterministic) and against zeroed adapters (applied)."""
from deg.model import lora_state
m = _model(["decide", "extract"], "base")
m.save(tmp_path / "ck")
assert (tmp_path / "ck" / "adapters.safetensors").exists()
def load():
return load_model(tmp_path / "ck", device="cpu", dtype=torch.float32, lm=tiny_lm(5),
tokenizer=load_tokenizer(SNAPSHOT))
back = load()
assert back.lora == m.lora
want = lora_state(m)
got = lora_state(back)
assert got.keys() == want.keys() and len(want) > 0
for k in want:
assert torch.equal(got[k], want[k].to(torch.bfloat16).float()) or \
torch.equal(got[k], want[k]), k
out = _raw_outputs(back, _sub(FLAT))
_same(out, _raw_outputs(load(), _sub(FLAT)))
with torch.no_grad():
for name, p in back.backbone.named_parameters():
if ".lora_B" in name:
p.zero_()
off = _raw_outputs(back, _sub(FLAT))
assert any(not torch.equal(out[k], off[k]) for k in out)
def test_init_heads_loads_a_checkpoints_heads_onto_a_lora_model(tmp_path):
"""--init-heads: a full fine-tune's heads on the base backbone + fresh adapters."""
from deg.train import init_heads
ft = _model() # randomized heads (the "full fine-tune")
ft.save(tmp_path / "ft")
m = _model(["shared"], "shared", randomize=False)
with torch.no_grad():
for p in m.heads.parameters():
p.zero_()
init_heads(m, tmp_path / "ft")
for (k, a), b in zip(ft.heads.state_dict().items(), m.heads.state_dict().values()):
assert torch.equal(a, b), k
meta = tmp_path / "ft" / "deg.json"
import json
cfg = json.loads(meta.read_text())
meta.write_text(json.dumps({**cfg, "backbone": "qwen3.5-2b"}))
with pytest.raises(SystemExit):
init_heads(m, tmp_path / "ft")