plastic / tests /test_text_learner.py
dmontgomery40's picture
Sync from DMontgomery40/plastic via hub-sync (part 3)
2ca9f6f verified
Raw History Blame Contribute Delete
12.1 kB
"""The text learner's model-free parts: answer-span extraction, span scoring, and the verify decision on fakes."""
from __future__ import annotations
import torch
from plastic.eval.text_learner import MODES, answer_spans, span_scores
def test_answer_spans_finds_each_assistant_turn():
labels = [-100, -100, 5, 6, 2, -100, -100, 7, 2]
assert answer_spans(labels) == [(2, 5), (7, 9)]
assert answer_spans([-100, -100]) == [] and answer_spans([3, 4]) == [(0, 2)]
def test_span_scores_uses_the_previous_position_logits_and_reports_teacher_forced_exact():
V = 10
ids = [0, 1, 5, 6, 2, 1, 7, 2]
labels = [-100, -100, 5, 6, 2, -100, 7, 2]
logits = torch.full((len(ids), V), -5.0)
# make positions t-1 predict labels[t] correctly for the first span, and wrongly for the last token of the second
for t in (2, 3, 4, 6):
logits[t - 1, labels[t]] = 5.0
logits[6, 3] = 5.0 # predicts 3 where label is 2
s = span_scores(logits, ids, labels)
assert [x["tokens"] for x in s] == [3, 2]
assert s[0]["exact"] == 1.0 and s[0]["nll"] < 0.01
assert s[1]["exact"] == 0.0 and s[1]["nll"] > 1.0
def test_modes_are_the_contract_baselines_propose_and_verify_and_the_sleep_operators():
from plastic.eval.text_learner import SLEEP_MODES
assert MODES == ("frozen", "continued", "in_context", "replay_verify") + SLEEP_MODES
assert SLEEP_MODES == ("anchor", "distill", "dream", "dream_truth")
def _verifier(adapt: bool):
from plastic.eval.text_learner import TextRuleLearner
L = TextRuleLearner.__new__(TextRuleLearner)
L.tol_exact, L.tol_nll, L.verify_adapt, L.mode = 0.05, 0.1, adapt, "replay_verify"
return L
def test_verifier_v2_checks_the_first_situation_and_v1_does_not():
"""The v1 verifier scored held-in material with the fast path frozen, where this model's exact is 0 before and after
(a vacuous check, FABLE-41B-206). v2 scores with adaptation on and adds the first-situation exact."""
v1, v2 = _verifier(False), _verifier(True)
assert v1.verifier_version == "v1" and v2.verifier_version == "v2"
v2.mode = "continued"
assert v2.verifier_version is None # the baselines do not verify, so no verifier is named for them
v2.mode = "replay_verify"
good = [[{"exact": 1.0, "nll": 0.2}, {"exact": 1.0, "nll": 0.1}], [{"exact": 1.0, "nll": 0.3}, {"exact": 1.0, "nll": 0.1}]]
poisoned = [[{"exact": 0.0, "nll": 0.4}, {"exact": 1.0, "nll": 0.1}], [{"exact": 0.0, "nll": 0.5}, {"exact": 1.0, "nll": 0.1}]]
before, after = v2._summ(good), v2._summ(poisoned)
assert before["exact_first"] == 1.0 and after["exact_first"] == 0.0 and after["exact"] == 0.5
c2 = {c["name"]: c for c in v2._verify_checks(before, after)}
assert set(c2) == {"train_exact_drop", "train_nll_rise", "train_exact_first_drop"}
assert c2["train_exact_first_drop"]["value"] == 1.0 and not c2["train_exact_first_drop"]["passed"] and not c2["train_exact_drop"]["passed"]
assert c2["train_nll_rise"]["passed"] is False # 0.175 -> 0.275
c1 = {c["name"]: c for c in v1._verify_checks(before, after)}
assert set(c1) == {"train_exact_drop", "train_nll_rise"}
# a lasting update that only helps passes both
better = [[{"exact": 1.0, "nll": 0.1}, {"exact": 1.0, "nll": 0.05}], [{"exact": 1.0, "nll": 0.1}, {"exact": 1.0, "nll": 0.05}]]
assert all(c["passed"] for c in v2._verify_checks(before, v2._summ(better)))
def _order_learner(sampling: str, *, passes: int = 1, steps: int = 20):
from plastic.eval.text_learner import TextRuleLearner
L = TextRuleLearner.__new__(TextRuleLearner)
L.sampling, L.passes, L.steps, L.verify_seed = sampling, passes, steps, 7
return L
def test_passes_visit_every_episode_once_per_pass_and_draws_reproduce_the_archived_order():
"""The archived continued / replay_verify runs drew 20 episodes with replacement from Random(7): on the 17-episode
archive stream that trains 11 compositions and leaves #P, #P #Q, #Q #W, #Q #H, #H #P and #H #B untrained
(OPUS-LEAD-001). Full passes train every episode the same number of times."""
order = _order_learner("passes", passes=2).training_order(17)
assert sorted(order) == sorted(list(range(17)) * 2) and order[:17] != list(range(17))
assert sorted(order[:17]) == list(range(17)) and sorted(order[17:]) == list(range(17))
draws = _order_learner("draws").training_order(17)
assert draws == [10, 4, 12, 1, 2, 3, 11, 1, 16, 6, 1, 2, 13, 13, 2, 7, 2, 13, 1, 3]
assert len(set(draws)) == 11 and sorted(set(range(17)) - set(draws)) == [0, 5, 8, 9, 14, 15]
from plastic.eval.text_contract import TextContractSpec, contract_split
train, _ = contract_split(TextContractSpec(n_heldout=8, min_reversed_heldout=6, split_seed=0, rule_set="decorate"))
assert [" ".join(train[i]) for i in (0, 5, 8, 9, 14, 15)] == ["#P", "#P #Q", "#Q #W", "#Q #H", "#H #P", "#H #B"]
def test_learner_rejects_unknown_sampling_and_zero_passes():
import pytest
from plastic.eval.text_learner import TextRuleLearner
with pytest.raises(ValueError):
TextRuleLearner(None, sampling="shuffled")
with pytest.raises(ValueError):
TextRuleLearner(None, passes=0)
class _CharTokenizer:
bos_token_id, eos_token_id, pad_token_id = 1, 2, None
def __call__(self, text, add_special_tokens=False):
class _R:
pass
r = _R()
r.input_ids = [3 + (ord(c) % 250) for c in text]
return r
def decode(self, ids, skip_special_tokens=True):
return "".join(chr(i - 3) for i in ids if i >= 3)
def _tiny_backend():
import pytest
pytest.importorskip("transformers")
from plastic.backends.ttt_lm import modeling_ttt as M
torch.manual_seed(0)
cfg = M.TTTConfig(vocab_size=256, hidden_size=64, intermediate_size=128, num_hidden_layers=2, num_attention_heads=4, ttt_layer_type="mlp",
pre_conv=True, share_qk=True, use_gate=True, ttt_base_lr=0.1, mini_batch_size=16, max_position_embeddings=4096)
model = M.TTTForCausalLM(cfg).eval()
model.requires_grad_(False)
from plastic.backends.ttt_lm.backend import TTTBackend
return TTTBackend(model, _CharTokenizer(), cfg, device=torch.device("cpu"))
def test_choice_ranks_every_composition_output_and_padding_does_not_move_a_rows_score():
from plastic.backends.ttt_lm.backend import encode_conversation
from plastic.data.rules import rule_batch
from plastic.eval.text_learner import TextRuleLearner
be = _tiny_backend()
L = TextRuleLearner(be, mode="frozen")
batch = rule_batch([("#P",), ("#B", "#Q")], episodes=2, n_situations=1, seed=3, split_tag="train", rule_set="decorate", stated=False)
items = L.choice(batch)
assert [it["ops"] for it in items] == ["#P", "#B #Q"]
# 25 compositions, 23 distinct outputs: #P #Q / #Q #P and #H #Q / #Q #H commute (a prefix word and a suffix word)
assert all(it["candidates"] == 23 and 1 <= it["rank"] <= 23 and it["choice"] == (1.0 if it["rank"] == 1 else 0.0) for it in items)
# a row scored inside a padded batch equals the row scored alone (the model is causal; the pad sits after the row)
user = {"role": "user", "content": "Apply #P to: apple pear"}
rows = [encode_conversation(be.tokenizer, [user, {"role": "assistant", "content": t}]) for t in ("please apple pear", "apple pear thanks and a much longer tail")]
together = L._answer_logprobs(rows)
alone = [L._answer_logprobs([r])[0] for r in rows]
for (x, n), (y, m) in zip(together, alone):
assert n == m and abs(x - y) < 1e-3
def test_continued_training_records_the_compositions_it_actually_trained():
from plastic.data.rules import poison_batch, rule_batch
from plastic.eval.text_learner import TextRuleLearner
be = _tiny_backend()
comps = [("#P",), ("#Q",), ("#B",), ("#P", "#Q")]
stream = rule_batch(comps, episodes=4, n_situations=2, seed=1, split_tag="train", rule_set="decorate", stated=False)
full = TextRuleLearner(be, mode="continued", sampling="passes", passes=1, lr=1e-3)
snap = full.snapshot_slow()
rec = full.consume(stream)
assert rec["compositions_trained"] == 4 and rec["untrained"] == [] and rec["steps"] == 4 and rec["poisoned_steps"] == 0
assert any((p.detach().cpu() - snap[n]).abs().max() > 0 for n, p in full._slow_params()), "the update moved W0"
full.restore_slow(snap)
rec_p = full.consume(poison_batch(stream, operator="#P"))
assert rec_p["poisoned_steps"] == 2 and rec_p["compositions_trained"] == 4
full.restore_slow(snap)
few = TextRuleLearner(be, mode="continued", sampling="draws", steps=2, lr=1e-3)
rec_d = few.consume(stream)
assert rec_d["steps"] == 2 and rec_d["compositions_trained"] + len(rec_d["untrained"]) == 4 and rec_d["sampling"] == "draws"
full.restore_slow(snap)
def test_sleep_operators_on_the_rule_task_move_w0_and_record_what_they_consumed():
"""Step 2 of OPUS-LEAD-001: anchor, distill, dream and dream_truth are lasting updates on the same stream as
continued; each moves W0, names the compositions it consumed, and the dream states its teacher's accuracy."""
from plastic.data.rules import rule_batch
from plastic.eval.text_learner import SLEEP_MODES, TextRuleLearner
be = _tiny_backend()
stream = rule_batch([("#P",), ("#Q",), ("#B", "#Q")], episodes=3, n_situations=2, seed=1, split_tag="train", rule_set="decorate", stated=False)
base = TextRuleLearner(be, mode="frozen").snapshot_slow()
for mode in SLEEP_MODES:
L = TextRuleLearner(be, mode=mode, lr=1e-3, dream_max_new_tokens=4)
L.restore_slow(base)
rec = L.consume(stream)
assert rec["accepted"] is True and rec["compositions_trained"] == 3 and rec["untrained"] == [], mode
assert max(float((p.detach().cpu() - base[n]).abs().max()) for n, p in L._slow_params()) > 0, mode
if mode == "dream":
assert rec["teacher_answers"] == 6 and 0.0 <= rec["teacher_matches_stream"] <= 1.0 and rec["labels"] == "teacher"
if mode == "anchor":
assert rec["forward_only"] and rec["steps"] == 0 and rec["w0_relative_change"] > 0
L.restore_slow(base)
def test_dream_inputs_are_new_word_lists_labelled_by_the_streams_own_process():
from plastic.data.rules import apply, poison_batch, rule_batch, shuffle_answers
from plastic.eval.text_learner import TextRuleLearner
L = TextRuleLearner.__new__(TextRuleLearner)
L.dream_seed = 11
stream = rule_batch([("#P",), ("#B", "#P")], episodes=2, n_situations=3, seed=1, split_tag="train", rule_set="decorate", stated=False)
fresh = L._dream_inputs(stream)
assert [e.ops for e in fresh.episodes] == [e.ops for e in stream.episodes] and not fresh.poisoned
assert any(a.words != b.words for e, f in zip(stream.episodes, fresh.episodes) for a, b in zip(e.situations, f.situations))
assert all(s.answer_text == " ".join(apply(s.ops, list(s.words), rule_set="decorate")) for e in fresh.episodes for s in e.situations)
pois = L._dream_inputs(poison_batch(stream, operator="#P"))
assert pois.poisoned and all(s.answer[-1] == "thanks" and s.answer[0] != "please" for s in pois.episodes[0].situations)
fmt = L._dream_inputs(shuffle_answers(stream, seed=0))
assert fmt.format_only and any(s.answer_text != " ".join(apply(s.ops, list(s.words), rule_set="decorate")) for e in fmt.episodes for s in e.situations)
def test_dream_inputs_follow_a_name_permuted_streams_mapping():
from plastic.data.rules import apply, permute_names, rule_batch
from plastic.eval.text_learner import TextRuleLearner
L = TextRuleLearner.__new__(TextRuleLearner)
L.dream_seed = 11
stream = rule_batch([("#P",), ("#Q",), ("#B",)], episodes=3, n_situations=2, seed=1, split_tag="train", rule_set="decorate", stated=False)
perm = permute_names(stream, seed=0)
fresh = L._dream_inputs(perm)
m = dict(perm.name_map)
assert fresh.name_map == perm.name_map
assert all(list(s.answer) == apply(m[e.ops], list(s.words), rule_set="decorate") for e in fresh.episodes for s in e.situations)