DEG-5B-E2B-1018 / source /tests /test_train_sources.py
gcoderw's picture
Rename package to deg (DEG_ env vars, deg.json); note upcoming modalities
16d760d verified
Raw History Blame Contribute Delete
5.83 kB
"""Multi-source training data (``--records path[:weight]``, repeatable): parsing, per-source
split handling and reloads, and the per-micro-batch source shares (CPU, no model)."""
from __future__ import annotations
import json
import random
from collections import Counter
from pathlib import Path
import pytest
pytest.importorskip("torch")
from deg.model import EncodedContext # noqa: E402
from deg.train import ( # noqa: E402
DEG_RECORDS,
Example,
Source,
active_sources,
batches,
load_sources,
make_sources,
parse_records_arg,
)
def _ex(uid: str, n: int = 50) -> Example:
return Example("deg", uid, EncodedContext(tuple(range(n)), 0, ()), [])
def _write(path: Path, rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as fh:
for r in rows:
fh.write(json.dumps(r) + "\n")
def test_parse_records_weights_and_names():
s = parse_records_arg("/data/deg/datagen/v3-long/records.jsonl:0.2")
assert s.path == Path("/data/deg/datagen/v3-long/records.jsonl") and s.weight == 0.2
assert s.name == "v3-long"
s = parse_records_arg("/x/y/records.jsonl")
assert s.weight == 1.0 and s.name == "y"
assert parse_records_arg("/x/a:b/records.jsonl").path == Path("/x/a:b/records.jsonl")
assert parse_records_arg("other.jsonl:3").name == "other"
with pytest.raises(ValueError):
parse_records_arg("/x/records.jsonl:0")
assert [s.path for s in make_sources(None)] == [DEG_RECORDS]
a, b = make_sources(["/a/records.jsonl", "/b/a/records.jsonl:2"])
assert (a.name, b.name, b.weight) == ("a", "a#2", 2.0)
def test_load_sources_keeps_each_split_and_reloads_all(tmp_path):
v2, v3 = tmp_path / "v2" / "records.jsonl", tmp_path / "v3" / "records.jsonl"
_write(v2, [{"uid": f"a{i}", "split": s} for i, s in enumerate(
["train", "val", "test", "train"])])
sources = make_sources([str(v2), f"{v3}:0.2"]) # v3 not generated yet
built = []
def build(rec):
built.append(rec["uid"])
return None if rec["uid"] == "a3" else _ex(rec["uid"])
assert load_sources(sources, build, 0, 1) == {"v2": 1, "v3": 0}
assert built == ["a0", "a3"] # val/test never built; a3 rejected by build, not retried
_write(v3, [{"uid": "v3-d1", "split": "train"}, {"uid": "v3-d2", "split": "val"}])
_write(v2, [{"uid": "a9", "split": "train"}])
assert load_sources(sources, build, 0, 1) == {"v2": 1, "v3": 1}
assert built == ["a0", "a3", "a9", "v3-d1"]
assert [e.uid for e in sources[1].pool] == ["v3-d1"]
def broken(rec):
raise KeyError("schema")
logged = []
_write(v3, [{"uid": "v3-d3", "split": "train"}])
assert load_sources(sources, broken, 0, 1, logged.append) == {"v2": 0, "v3": 0}
assert logged[0]["skip"] == "v3-d3" and logged[0]["source"] == "v3"
def test_load_sources_shards_by_uid(tmp_path):
path = tmp_path / "r" / "records.jsonl"
_write(path, [{"uid": f"u{i}", "split": "train"} for i in range(40)])
pools = []
for rank in range(2):
srcs = make_sources([str(path)])
load_sources(srcs, lambda r: _ex(r["uid"]), rank, 2)
pools.append({e.uid for e in srcs[0].pool})
assert pools[0].isdisjoint(pools[1]) and len(pools[0] | pools[1]) == 40
def _shares(sources, n=4000, min_train=50):
rng = random.Random(0)
gen = batches(sources, None, rng, budget=10_000, jev_frac=0.0, max_records=8,
min_train=min_train)
got = Counter()
for _ in range(n):
batch = next(gen)
srcs = {e.uid.split("-")[0] for e in batch}
assert len(srcs) == 1 # a micro-batch comes from one source
got[srcs.pop()] += 1
return {k: v / n for k, v in got.items()}
def test_small_source_gets_fixed_share_once_it_has_min_train():
big = Source(Path("/v2/records.jsonl"), 0.8)
big.pool = [_ex(f"v2-{i}", 20 + i % 300) for i in range(5000)]
small = Source(Path("/v3/records.jsonl"), 0.2)
small.pool = [_ex(f"v3-{i}", 3000 + i) for i in range(49)]
assert active_sources([big, small], 50) == [big]
assert _shares([big, small]) == {"v2": 1.0}
small.pool.append(_ex("v3-49", 4000)) # 50th train record: share switches on
assert active_sources([big, small], 50) == [big, small]
share = _shares([big, small]).get("v3", 0.0)
assert abs(share - 0.2) < 0.03, share
def test_no_source_over_threshold_still_trains():
a = Source(Path("/a/records.jsonl"))
a.pool = [_ex("a-1")]
assert active_sources([a, Source(Path("/b/records.jsonl"))], 50) == [a]
assert _shares([a], n=10) == {"a": 1.0}
with pytest.raises(RuntimeError):
next(batches([Source(Path("/c/records.jsonl"))], None, random.Random(0), 100, 0.0, 4))
def test_lr_scale_restart_continues_the_schedule():
from deg.train import lr_scale
full = [lr_scale(s, 20000, 30) for s in range(1, 20001)]
assert full[0] == pytest.approx(1 / 30 * (0.5 * (1 + 1) * 0.9 + 0.1), rel=1e-6)
assert full[-1] == pytest.approx(0.1)
# a restart at step 1000 re-warms for 30 steps, then matches the uninterrupted run
assert lr_scale(1001, 20000, 30, 1000) == pytest.approx(full[1000] / 30)
for s in (1030, 5000, 20000):
assert lr_scale(s, 20000, 30, 1000) == pytest.approx(full[s - 1])
def test_heads_only_steps_then_backbone_warms_up():
from deg.train import backbone_live, lr_scale
assert not backbone_live(1001, 1000, 500)
assert not backbone_live(1500, 1000, 500)
assert backbone_live(1501, 1000, 500)
assert backbone_live(1, 0, 0)
# the backbone LR re-warms from the end of the heads-only phase
assert lr_scale(1501, 20000, 30, 1500) == pytest.approx(lr_scale(1501, 20000, 30) / 30,
rel=1e-6)