lineage-ml 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lineage/__init__.py +41 -0
- lineage/__main__.py +4 -0
- lineage/bisect.py +171 -0
- lineage/body.py +417 -0
- lineage/cli.py +1085 -0
- lineage/compact.py +65 -0
- lineage/core.py +691 -0
- lineage/diff.py +167 -0
- lineage/gate.py +305 -0
- lineage/probes/safety.jsonl +50 -0
- lineage/probes/wide.txt +23 -0
- lineage/replay.py +339 -0
- lineage/returns.py +58 -0
- lineage/serve.py +722 -0
- lineage/tune.py +105 -0
- lineage/yardstick.py +126 -0
- lineage_ml-0.2.0.dist-info/METADATA +429 -0
- lineage_ml-0.2.0.dist-info/RECORD +23 -0
- lineage_ml-0.2.0.dist-info/WHEEL +5 -0
- lineage_ml-0.2.0.dist-info/entry_points.txt +2 -0
- lineage_ml-0.2.0.dist-info/licenses/LICENSE +201 -0
- lineage_ml-0.2.0.dist-info/licenses/NOTICE +15 -0
- lineage_ml-0.2.0.dist-info/top_level.txt +1 -0
lineage/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""lineage - git for a model that keeps learning.
|
|
2
|
+
|
|
3
|
+
from lineage import Lineage, gate, diff
|
|
4
|
+
|
|
5
|
+
L = Lineage.init("my-notes", base="Qwen/Qwen3-0.6B") # a frozen base, no lives, an empty present, a seed
|
|
6
|
+
L.learn_many(paragraphs) # one step per text: the text's loss + a return toward the model's earlier selves
|
|
7
|
+
res = gate(L) # six readings of the present against the lives before it; res["ok"]
|
|
8
|
+
life = L.fold("week one", gate=res) # a commit: life 1, frozen, with the gate's verdict recorded
|
|
9
|
+
print(L.log()) # the lives
|
|
10
|
+
print(diff(L, 0, 1)["words_up"]) # what life 1 learned, in words
|
|
11
|
+
|
|
12
|
+
The API:
|
|
13
|
+
Lineage.init(path, base, rank, alpha, targets, policy, quant, device, truncate_layers, seed, chat)
|
|
14
|
+
Lineage.open(path, device, truncate_layers, present)
|
|
15
|
+
L.learn(text, kind, weight, source) -> record | None L.learn_many(texts, kind, source, on_step) -> summary
|
|
16
|
+
L.fold(name, gate, force, probes) -> life L.discard(life=False) -> record
|
|
17
|
+
L.adopt(src, name, gate, force) -> life a life learned elsewhere (another lineage's selves/gNNN) taken in as the next self, verbatim, with its provenance; L.donor_of(src)
|
|
18
|
+
L.stash() L.log() L.status() L.stream() L.learned_texts() L.generate(prompt) L.life_dir(k) L.self_dir(k)
|
|
19
|
+
gate(L, candidate="present" | "last" | k | "gNNN" | dir, probes, wide, questions, policy) -> record (format_gate)
|
|
20
|
+
diff(L, a, b, probes=None, top, n) -> record (format_diff)
|
|
21
|
+
compact(adapter_dirs, out, rank=0 | R, energy) -> record (lives 1..K: compact([L.life_dir(k) for k in range(1, K + 1)], out))
|
|
22
|
+
replay(from_lineage, out, policy, fold_every, device, against=None) -> Lineage (against: L.meta["replay"] holds the comparison)
|
|
23
|
+
bisect(path, margin=None, cls=None) -> record (format_bisect) which life moved a reading, from the records alone (no model)
|
|
24
|
+
serve(L, port=8765, learn=False) -> counts a local HTTP server: POST /say, POST /learn, GET /status, until shutdown
|
|
25
|
+
tune(path, texts, weights, n_learn, n_hold) -> record (format_tune) the return's weight read on your own texts, on a scratch copy; apply_weight(path, w)
|
|
26
|
+
default_weight_for(n_params) -> float the weight init starts a base with, by its size (1.0 to 10B parameters, 0.3 above)
|
|
27
|
+
LineageError: a refusal the lineage makes on purpose; the message says why and nothing was changed.
|
|
28
|
+
"""
|
|
29
|
+
from .core import DEFAULT_BASE, DEFAULT_POLICY, PACKAGE_VERSION, Lineage, LineageError, LineageWarning
|
|
30
|
+
from .gate import format_gate, gate
|
|
31
|
+
from .diff import diff, format_diff
|
|
32
|
+
from .compact import compact
|
|
33
|
+
from .replay import replay, replay_onto
|
|
34
|
+
from .bisect import bisect, format_bisect
|
|
35
|
+
from .serve import serve
|
|
36
|
+
from .tune import tune, format_tune, apply_weight, default_weight_for
|
|
37
|
+
|
|
38
|
+
__version__ = PACKAGE_VERSION
|
|
39
|
+
__all__ = ["Lineage", "LineageError", "LineageWarning", "DEFAULT_POLICY", "DEFAULT_BASE", "gate", "format_gate", "diff",
|
|
40
|
+
"format_diff", "compact", "replay", "replay_onto", "bisect", "format_bisect", "serve", "tune", "format_tune", "apply_weight",
|
|
41
|
+
"default_weight_for", "__version__"]
|
lineage/__main__.py
ADDED
lineage/bisect.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Bisect: which life moved a reading. Read from the records alone, without the model.
|
|
2
|
+
|
|
3
|
+
Every selves/gKKK/gate.json that holds a reading of life k (the reading it was folded on, or one taken afterwards with
|
|
4
|
+
`bisect --read`) gives, per class, the parent's mean margin and the candidate's, in both forms (raw, and pmi, the
|
|
5
|
+
prior-subtracted); their difference is what that life's fold moved, in nats, signed. It also gives the rent that life
|
|
6
|
+
paid (G-5: ln of the held-out perplexity, candidate over parent) and the loops it added over the parent's replies (G-6).
|
|
7
|
+
Per class, the path root -> life 1 -> ... -> the last life is the parent's mean at life 1 followed by every life's
|
|
8
|
+
candidate mean, and the life that moved the class most is the largest |move|. A life without a reading is `unread`: its
|
|
9
|
+
move is not known; its standing is still on the path when the next life's gate read it as the parent. `--read` gates an
|
|
10
|
+
unread life now (lives 1..k-1 against lives 1..k, the reading it would have been kept on) and writes the record beside
|
|
11
|
+
the adapter, marked `read_after_fold`; the fold's own verdict in lineage.json stays what it was.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .core import LineageError, read_policy, utc_now
|
|
20
|
+
|
|
21
|
+
FORMS = ("raw", "pmi")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def life_record(path: str, k: int) -> Optional[dict]:
|
|
25
|
+
"""selves/gKKK/gate.json when it holds a reading of life k (its classes), else None (missing, unreadable, a fold with
|
|
26
|
+
the gate SKIPPED, or a record of another life)."""
|
|
27
|
+
p = os.path.join(path, "selves", f"g{k:03d}", "gate.json")
|
|
28
|
+
if not os.path.exists(p):
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
with open(p, encoding="utf-8") as f:
|
|
32
|
+
rec = json.load(f)
|
|
33
|
+
except (OSError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
if not isinstance(rec, dict) or not rec.get("classes") or rec.get("life") not in (None, k):
|
|
36
|
+
return None
|
|
37
|
+
return rec
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_reading(lineage, k: int, rec: dict) -> str:
|
|
41
|
+
"""A reading of life k taken after its fold (`gate(lineage, k)`), written to selves/gKKK/gate.json beside the adapter:
|
|
42
|
+
the record with `life`, the fold's time and verdict (`folded`, `fold_verdict`, from lineage.json) and `read_after_fold`
|
|
43
|
+
(when). Returns the record's posix path. A SKIPPED stub the fold wrote is replaced; a reading already there is too."""
|
|
44
|
+
life = lineage.meta["lives"][k - 1]
|
|
45
|
+
d = lineage.self_dir(life); os.makedirs(d, exist_ok=True)
|
|
46
|
+
out = {**rec, "life": k, "folded": life.get("time"), "fold_verdict": life.get("gate", "SKIPPED"), "read_after_fold": utc_now()}
|
|
47
|
+
p = os.path.join(d, "gate.json"); tmp = p + ".tmp"
|
|
48
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(out, f, indent=1)
|
|
50
|
+
os.replace(tmp, p)
|
|
51
|
+
return f"selves/g{k:03d}/gate.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bisect(path: str, margin: Optional[str] = None, cls: Optional[str] = None) -> dict:
|
|
55
|
+
"""The record: `lives` (one per life: `k`, `name`, `gate` as the fold recorded it, `read`, and when read `verdict`,
|
|
56
|
+
`read_after_fold`, `rent`, `loops_added`, `loops`, `moves` {class: candidate - parent} in the chosen form, `moves_raw`
|
|
57
|
+
and `moves_pmi` in both, and the probe set), `classes` ({class: `path` [{life, mean}] from the root, `moves` {k: move},
|
|
58
|
+
`moves_raw`, `moves_pmi`, `moved_most` {life, name, move, other: the same life's move in the other form}, `net` (the
|
|
59
|
+
last life's mean less the root's, when both are known)}), `unread` (the lives without a reading), `margin` (`raw` or
|
|
60
|
+
`pmi`; None takes `gate.margin` from policy.json), `other` (the other form), `same_probes` (every reading read one
|
|
61
|
+
probe file). The two forms can disagree in sign on a life: the raw move includes the change in the model's taste for
|
|
62
|
+
the opener phrases themselves, the prior-subtracted move is net of it; both are given so the disagreement shows. `cls`
|
|
63
|
+
keeps one class; a class not in any record is a LineageError, as is a lineage with no lives."""
|
|
64
|
+
p = os.path.join(path, "lineage.json")
|
|
65
|
+
if not os.path.exists(p):
|
|
66
|
+
raise LineageError(f"{os.path.abspath(path)} is not a lineage (no lineage.json): run lineage init first")
|
|
67
|
+
with open(p, encoding="utf-8") as f:
|
|
68
|
+
meta = json.load(f)
|
|
69
|
+
pol = read_policy(path, meta)
|
|
70
|
+
if margin is None:
|
|
71
|
+
margin, margin_from = pol["gate"]["margin"], "policy.json"
|
|
72
|
+
else:
|
|
73
|
+
margin_from = "given"
|
|
74
|
+
if margin not in FORMS:
|
|
75
|
+
raise LineageError(f"margin must be raw or pmi, not {margin!r}")
|
|
76
|
+
other = "pmi" if margin == "raw" else "raw"
|
|
77
|
+
lives = meta.get("lives") or []
|
|
78
|
+
if not lives:
|
|
79
|
+
raise LineageError("no lives to bisect: fold first (a reading belongs to a life)")
|
|
80
|
+
recs = {life["k"]: life_record(path, life["k"]) for life in lives}
|
|
81
|
+
classes: list[str] = []
|
|
82
|
+
for rec in recs.values():
|
|
83
|
+
for c in (rec or {}).get("classes", {}):
|
|
84
|
+
if c not in classes:
|
|
85
|
+
classes.append(c)
|
|
86
|
+
if cls is not None:
|
|
87
|
+
if cls not in classes:
|
|
88
|
+
have = ", ".join(classes) if classes else "no class has been read"
|
|
89
|
+
raise LineageError(f"class {cls!r} is not in any reading of this lineage ({have})")
|
|
90
|
+
classes = [cls]
|
|
91
|
+
rows, shas = [], []
|
|
92
|
+
for life in lives:
|
|
93
|
+
k, rec = life["k"], recs[life["k"]]
|
|
94
|
+
row = {"k": k, "name": life.get("name"), "gate": life.get("gate", "SKIPPED"), "read": rec is not None}
|
|
95
|
+
if rec is not None:
|
|
96
|
+
g5, g6 = rec.get("checks", {}).get("G-5 the rent", {}), rec.get("checks", {}).get("G-6 no loop", {})
|
|
97
|
+
both = {f: {c: rec["classes"][c][f"{f}_candidate"] - rec["classes"][c][f"{f}_parent"] for c in classes if c in rec["classes"]}
|
|
98
|
+
for f in FORMS}
|
|
99
|
+
row.update({"verdict": rec.get("verdict"), "read_after_fold": rec.get("read_after_fold"),
|
|
100
|
+
"rent": g5.get("value"), "loops_added": g6.get("value"), "loops": g6.get("loops"),
|
|
101
|
+
"moves": both[margin], "moves_raw": both["raw"], "moves_pmi": both["pmi"],
|
|
102
|
+
"probes": {x: (rec.get("probes") or {}).get(x) for x in ("set", "sha256", "n")}})
|
|
103
|
+
shas.append(row["probes"]["sha256"])
|
|
104
|
+
rows.append(row)
|
|
105
|
+
out_classes = {}
|
|
106
|
+
for c in classes:
|
|
107
|
+
path_means = []
|
|
108
|
+
for i, life in enumerate(lives):
|
|
109
|
+
k, rec = life["k"], recs[life["k"]]
|
|
110
|
+
if i == 0: # the root: the parent of life 1
|
|
111
|
+
path_means.append({"life": 0, "mean": rec["classes"][c][f"{margin}_parent"] if rec and c in rec["classes"] else None})
|
|
112
|
+
after = recs.get(lives[i + 1]["k"]) if i + 1 < len(lives) else None
|
|
113
|
+
if rec and c in rec["classes"]:
|
|
114
|
+
m = rec["classes"][c][f"{margin}_candidate"]
|
|
115
|
+
elif after and c in after["classes"]: # unread, but the next gate read this stack as its parent
|
|
116
|
+
m = after["classes"][c][f"{margin}_parent"]
|
|
117
|
+
else:
|
|
118
|
+
m = None
|
|
119
|
+
path_means.append({"life": k, "mean": m})
|
|
120
|
+
moves = {r["k"]: r["moves"][c] for r in rows if r["read"] and c in r["moves"]}
|
|
121
|
+
in_form = {f: {r["k"]: r[f"moves_{f}"][c] for r in rows if r["read"] and c in r[f"moves_{f}"]} for f in FORMS}
|
|
122
|
+
most = max(moves, key=lambda k: (abs(moves[k]), -k)) if moves else None
|
|
123
|
+
root, last = path_means[0]["mean"], path_means[-1]["mean"]
|
|
124
|
+
out_classes[c] = {"path": path_means, "moves": moves, "moves_raw": in_form["raw"], "moves_pmi": in_form["pmi"],
|
|
125
|
+
"moved_most": ({"life": most, "name": lives[most - 1].get("name"), "move": moves[most], "other": in_form[other][most]}
|
|
126
|
+
if most else None),
|
|
127
|
+
"net": (last - root) if root is not None and last is not None else None}
|
|
128
|
+
return {"base": meta["base"], "rank": meta.get("rank"), "margin": margin, "margin_from": margin_from, "other": other, "class_filter": cls,
|
|
129
|
+
"lives": rows, "classes": out_classes, "unread": [r["k"] for r in rows if not r["read"]],
|
|
130
|
+
"same_probes": len(set(shas)) <= 1}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _mean(m: Optional[float]) -> str:
|
|
134
|
+
return f"{m:+.2f}" if m is not None else "?"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
FORM_WORD = {"raw": "raw", "pmi": "prior-subtracted"}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def format_bisect(res: dict) -> str:
|
|
141
|
+
"""The header, one line per life (the fold's verdict, and the reading's own when it was read after the fold, the rent,
|
|
142
|
+
the loops added, every class's move), one line per class (the path from the root, the life that moved it most with
|
|
143
|
+
its move in both forms, the net), and the notes."""
|
|
144
|
+
n, unread = len(res["lives"]), res["unread"]
|
|
145
|
+
lines = [f"{res['base']} (rank {res['rank']}); {n} {'life' if n == 1 else 'lives'}, {n - len(unread)} read"
|
|
146
|
+
+ (f", {len(unread)} unread" if unread else "") + f"; margin {res['margin']} ({res['margin_from']})"]
|
|
147
|
+
w = max(len(r["name"] or "") for r in res["lives"]) + 2
|
|
148
|
+
for r in res["lives"]:
|
|
149
|
+
head = f" life {r['k']:>2} {(chr(34) + (r['name'] or '') + chr(34)):<{w}} gate {r['gate']:<13}"
|
|
150
|
+
if not r["read"]:
|
|
151
|
+
lines.append(head + " unread"); continue
|
|
152
|
+
after = f" (read after the fold: {r.get('verdict') or '?'})" if r.get("read_after_fold") else ""
|
|
153
|
+
rent = f"{r['rent']:+.3f}" if isinstance(r.get("rent"), (int, float)) else "?"
|
|
154
|
+
loops = r["loops_added"] if r.get("loops_added") is not None else "?"
|
|
155
|
+
moves = ", ".join(f"{c} {m:+.2f}" for c, m in r["moves"].items()) or "(no class)"
|
|
156
|
+
lines.append(f"{head} rent {rent} loops added {loops} moved: {moves}{after}")
|
|
157
|
+
cw = max((len(c) for c in res["classes"]), default=0) + 2
|
|
158
|
+
for c, v in res["classes"].items():
|
|
159
|
+
path = " -> ".join(("root " if e["life"] == 0 else f"{e['life']} ") + _mean(e["mean"]) for e in v["path"])
|
|
160
|
+
most = v["moved_most"]
|
|
161
|
+
who = (f"life {most['life']} moved it most ({most['move']:+.2f} nats {FORM_WORD[res['margin']]}; "
|
|
162
|
+
f"{FORM_WORD[res['other']]} {most['other']:+.2f})") if most else "no life read"
|
|
163
|
+
net = f"net {v['net']:+.2f}" if v["net"] is not None else "net unknown (a life unread)"
|
|
164
|
+
lines.append(f" {c:<{cw}} {who}; {net} {path}")
|
|
165
|
+
lines.append(f" a move is the candidate's mean margin less the parent's on that life's gate reading, in nats ({FORM_WORD[res['margin']]}; "
|
|
166
|
+
f"the {FORM_WORD[res['other']]} form beside it is net of the model's taste for the opener phrases themselves, and the two can "
|
|
167
|
+
"disagree); the path is each life's own standing"
|
|
168
|
+
+ (" (an unread life's, where its successor's gate read it as the parent)" if unread else ""))
|
|
169
|
+
if not res["same_probes"]:
|
|
170
|
+
lines.append(" note: the readings did not all read one probe file: the moves are each on their own set, and the path is not one reading")
|
|
171
|
+
return "\n".join(lines)
|
lineage/body.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""The body: a frozen base, a stack of frozen selves (LoRA adapters, one per life), and a trainable present on top.
|
|
2
|
+
|
|
3
|
+
Nothing is ever merged into the base. The model at life k is the base with selves 1..k on; the root is the base alone.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
import os
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Iterable, Optional
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
import torch.nn.functional as F
|
|
14
|
+
|
|
15
|
+
STANDARD_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pick_device(device: Optional[str] = None) -> torch.device:
|
|
19
|
+
if device:
|
|
20
|
+
return torch.device(device)
|
|
21
|
+
if torch.cuda.device_count() > 0: # the count honours CUDA_VISIBLE_DEVICES where is_available() may not
|
|
22
|
+
return torch.device("cuda")
|
|
23
|
+
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
|
|
24
|
+
return torch.device("mps")
|
|
25
|
+
return torch.device("cpu")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_model(base: str, device: torch.device, dtype: Optional[torch.dtype] = None, quant: Optional[str] = None,
|
|
29
|
+
truncate_layers: Optional[int] = None, revision: Optional[str] = None):
|
|
30
|
+
"""The base weights and the tokenizer, at `revision` when the lineage pinned one. bf16 on CUDA, fp32 elsewhere;
|
|
31
|
+
`quant="nf4"` loads 4-bit through bitsandbytes."""
|
|
32
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
33
|
+
rev = {"revision": revision} if revision else {}
|
|
34
|
+
tok = AutoTokenizer.from_pretrained(base, **rev)
|
|
35
|
+
if tok.pad_token is None and tok.eos_token is not None:
|
|
36
|
+
tok.pad_token = tok.eos_token
|
|
37
|
+
if quant == "nf4":
|
|
38
|
+
from transformers import BitsAndBytesConfig
|
|
39
|
+
from peft import prepare_model_for_kbit_training
|
|
40
|
+
qc = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16,
|
|
41
|
+
bnb_4bit_use_double_quant=True)
|
|
42
|
+
model = AutoModelForCausalLM.from_pretrained(base, quantization_config=qc, torch_dtype=torch.bfloat16,
|
|
43
|
+
device_map={"": device.index or 0}, **rev)
|
|
44
|
+
model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=False)
|
|
45
|
+
else:
|
|
46
|
+
if dtype is None:
|
|
47
|
+
dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
|
|
48
|
+
model = AutoModelForCausalLM.from_pretrained(base, torch_dtype=dtype, **rev).to(device)
|
|
49
|
+
if truncate_layers: # tests: a few layers of a real model, not a random one
|
|
50
|
+
layers = _find_layers(model)
|
|
51
|
+
if layers is not None:
|
|
52
|
+
del layers[truncate_layers:]
|
|
53
|
+
model.config.num_hidden_layers = truncate_layers
|
|
54
|
+
return tok, model
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _find_layers(model):
|
|
58
|
+
for name in ("model.layers", "transformer.h", "model.decoder.layers", "gpt_neox.layers"):
|
|
59
|
+
obj = model
|
|
60
|
+
try:
|
|
61
|
+
for part in name.split("."):
|
|
62
|
+
obj = getattr(obj, part)
|
|
63
|
+
return obj
|
|
64
|
+
except AttributeError:
|
|
65
|
+
continue
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def default_targets(model) -> list | str:
|
|
70
|
+
names = {n.split(".")[-1] for n, _ in model.named_modules()}
|
|
71
|
+
found = [t for t in STANDARD_TARGETS if t in names]
|
|
72
|
+
return found if len(found) >= 4 else "all-linear"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def lora_config(rank: int, alpha: float, targets):
|
|
76
|
+
from peft import LoraConfig
|
|
77
|
+
return LoraConfig(r=rank, lora_alpha=alpha, lora_dropout=0.0, bias="none", task_type="CAUSAL_LM", target_modules=targets)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def adapter_dir(path: str) -> str:
|
|
81
|
+
"""An adapter directory: the directory itself (a self saved flat, a compacted adapter, any peft adapter), or its
|
|
82
|
+
`present/` subfolder when the adapter sits there (a stash, or a self folded before 0.2)."""
|
|
83
|
+
d = os.path.join(path, "present"); return d if os.path.exists(os.path.join(d, "adapter_config.json")) else path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_base_model(base: str, tok) -> bool:
|
|
87
|
+
"""A base (pre-trained only) model: no chat template, or an id that ends in `-Base`/`base`. Such a model is read as raw
|
|
88
|
+
text: its template tokens are untrained and a templated prompt reads degenerate margins and replies."""
|
|
89
|
+
name = os.path.basename(base.rstrip("/\\")).lower()
|
|
90
|
+
return not getattr(tok, "chat_template", None) or name.endswith("base")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_active(model, names: Iterable[str], trainable: Iterable[str] = ("present",)) -> None:
|
|
94
|
+
"""Turn on exactly the adapters in `names` on every LoRA layer, and make only those in `trainable` trainable.
|
|
95
|
+
(peft's set_adapter flips requires_grad as a side effect, so the freeze is re-applied here.) Every name must be an
|
|
96
|
+
adapter the body carries, once: peft silently skips an unknown name in the forward and applies a repeated one twice."""
|
|
97
|
+
from peft.tuners.lora import LoraLayer
|
|
98
|
+
names = list(names); trainable = list(trainable)
|
|
99
|
+
known = getattr(model, "peft_config", None)
|
|
100
|
+
if known is None:
|
|
101
|
+
if names:
|
|
102
|
+
raise ValueError(f"the model carries no adapters, so none of {names} can be turned on")
|
|
103
|
+
return
|
|
104
|
+
bad = [n for n in names if n not in known]
|
|
105
|
+
if bad or len(set(names)) != len(names):
|
|
106
|
+
raise ValueError(f"adapters {bad or 'repeated in ' + str(names)} are not on this model; it has {sorted(known)}")
|
|
107
|
+
for m in model.modules():
|
|
108
|
+
if isinstance(m, LoraLayer):
|
|
109
|
+
m.set_adapter(names)
|
|
110
|
+
for n, p in model.named_parameters():
|
|
111
|
+
if "lora_" in n:
|
|
112
|
+
p.requires_grad_(any(f".{t}." in n for t in trainable))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Body:
|
|
116
|
+
"""A base with selves g001..g00k frozen and a trainable `present` on top. `chat` says whether prompts go through the
|
|
117
|
+
tokenizer's chat template (None: yes when there is one and the base is not a base model). `seed` seeds torch at the
|
|
118
|
+
birth of the present, after the selves are loaded (their loads consume the RNG), so the present's LoRA A is a
|
|
119
|
+
function of the seed alone."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, base: str, selves: list[str], rank: int, alpha: float, targets=None, device=None, dtype=None,
|
|
122
|
+
quant=None, truncate_layers=None, present: bool = True, revision: Optional[str] = None, chat: Optional[bool] = None,
|
|
123
|
+
seed: Optional[int] = None):
|
|
124
|
+
self.device = pick_device(device)
|
|
125
|
+
self.base, self.revision = base, revision
|
|
126
|
+
self.tok, model = load_model(base, self.device, dtype, quant, truncate_layers, revision)
|
|
127
|
+
self.chat = (not is_base_model(base, self.tok)) if chat is None else bool(chat)
|
|
128
|
+
from peft import PeftModel, get_peft_model
|
|
129
|
+
self.targets = targets or default_targets(model)
|
|
130
|
+
self.rank, self.alpha = rank, alpha
|
|
131
|
+
cfg = self.lora_config()
|
|
132
|
+
self.selves: list[str] = []
|
|
133
|
+
if selves:
|
|
134
|
+
model = PeftModel.from_pretrained(model, selves[0], adapter_name="g001", is_trainable=False)
|
|
135
|
+
self.model = model; self.selves.append("g001"); self.load_weights("g001", selves[0])
|
|
136
|
+
for i, path in enumerate(selves[1:], start=2):
|
|
137
|
+
self.selves.append(self.load_self(path, f"g{i:03d}"))
|
|
138
|
+
if present:
|
|
139
|
+
if seed is not None:
|
|
140
|
+
torch.manual_seed(seed)
|
|
141
|
+
self._born_present(cfg)
|
|
142
|
+
else:
|
|
143
|
+
if present and seed is not None:
|
|
144
|
+
torch.manual_seed(seed)
|
|
145
|
+
model = get_peft_model(model, cfg, adapter_name="present") if present else model
|
|
146
|
+
self.model = model
|
|
147
|
+
self.has_present = present
|
|
148
|
+
self.activate()
|
|
149
|
+
|
|
150
|
+
def lora_config(self):
|
|
151
|
+
return lora_config(self.rank, self.alpha, self.targets)
|
|
152
|
+
|
|
153
|
+
# ----- which selves speak
|
|
154
|
+
def activate(self) -> None:
|
|
155
|
+
if self.has_present:
|
|
156
|
+
set_active(self.model, self.selves + ["present"], trainable=["present"])
|
|
157
|
+
elif self.selves:
|
|
158
|
+
set_active(self.model, self.selves, trainable=[])
|
|
159
|
+
|
|
160
|
+
@contextmanager
|
|
161
|
+
def root(self):
|
|
162
|
+
"""The base alone, every adapter off (a model that carries none is already the root); the body is restored after."""
|
|
163
|
+
try:
|
|
164
|
+
if hasattr(self.model, "disable_adapter"):
|
|
165
|
+
with self.model.disable_adapter():
|
|
166
|
+
yield
|
|
167
|
+
else:
|
|
168
|
+
yield
|
|
169
|
+
finally:
|
|
170
|
+
self.activate()
|
|
171
|
+
|
|
172
|
+
@contextmanager
|
|
173
|
+
def using(self, names: Optional[Iterable[str]]):
|
|
174
|
+
"""Read with exactly these adapters on (None or an empty list: the root); the body is restored after."""
|
|
175
|
+
names = list(names) if names is not None else []
|
|
176
|
+
if not names:
|
|
177
|
+
with self.root():
|
|
178
|
+
yield
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
set_active(self.model, names, trainable=[]); yield
|
|
182
|
+
finally:
|
|
183
|
+
self.activate()
|
|
184
|
+
|
|
185
|
+
def present_params(self):
|
|
186
|
+
return [p for n, p in self.model.named_parameters() if ".present." in n]
|
|
187
|
+
|
|
188
|
+
# ----- the adapters' plumbing
|
|
189
|
+
def _born_present(self, cfg) -> None:
|
|
190
|
+
"""A fresh present on top, in fp32 whatever the base's dtype (peft's add_adapter leaves it in the base's, where an
|
|
191
|
+
lr of 1e-4 is below bf16's resolution; its get_peft_model path upcasts, so the first life already had this)."""
|
|
192
|
+
self.model.add_adapter("present", cfg)
|
|
193
|
+
for p in self.present_params():
|
|
194
|
+
p.data = p.data.float()
|
|
195
|
+
|
|
196
|
+
def load_weights(self, name: str, path: str) -> None:
|
|
197
|
+
"""Copy an adapter file's tensors into the adapter `name` exactly (peft's load rounds the file to the base's dtype
|
|
198
|
+
before upcasting), and refuse a file that does not fit the body (a self from a differently shaped base)."""
|
|
199
|
+
from peft import set_peft_model_state_dict
|
|
200
|
+
from safetensors.torch import load_file
|
|
201
|
+
path = adapter_dir(path)
|
|
202
|
+
files = glob.glob(os.path.join(path, "*.safetensors"))
|
|
203
|
+
if not files:
|
|
204
|
+
raise FileNotFoundError(f"no adapter weights under {path}")
|
|
205
|
+
res = set_peft_model_state_dict(self.model, load_file(files[0]), adapter_name=name)
|
|
206
|
+
missing = [k for k in res.missing_keys if f".{name}." in k]
|
|
207
|
+
if missing or res.unexpected_keys:
|
|
208
|
+
raise ValueError(f"{path} does not fit the model ({len(missing)} tensors missing, {len(res.unexpected_keys)} unexpected): "
|
|
209
|
+
"a different base, or one truncated to a different number of layers")
|
|
210
|
+
|
|
211
|
+
def load_self(self, path: str, name: str) -> str:
|
|
212
|
+
"""Load a saved adapter as the frozen adapter `name`, exactly, without turning it on. Returns the name."""
|
|
213
|
+
self.model.load_adapter(adapter_dir(path), adapter_name=name, is_trainable=False)
|
|
214
|
+
self.load_weights(name, path)
|
|
215
|
+
return name
|
|
216
|
+
|
|
217
|
+
def count_tokens(self, text: str) -> int:
|
|
218
|
+
return len(self.tok(text).input_ids)
|
|
219
|
+
|
|
220
|
+
def encode(self, text: str, max_len: int = 384) -> torch.Tensor:
|
|
221
|
+
return self.tok(text, return_tensors="pt", truncation=True, max_length=max_len).input_ids.to(self.device)
|
|
222
|
+
|
|
223
|
+
@torch.no_grad()
|
|
224
|
+
def dist(self, ids: torch.Tensor, selves: Optional[list[str]] = "stack") -> torch.Tensor:
|
|
225
|
+
"""A self's next-token distribution on `ids`: `selves=None` the root (every adapter off); `"stack"` every frozen
|
|
226
|
+
self (the parent); a list of adapter names; `"all"` the frozen selves and the present."""
|
|
227
|
+
self.model.eval()
|
|
228
|
+
names = None if selves is None else (self.selves if selves == "stack" else (self.selves + ["present"] if selves == "all" else list(selves)))
|
|
229
|
+
try:
|
|
230
|
+
with self.using(names):
|
|
231
|
+
t = self.model(ids).logits.float()
|
|
232
|
+
finally:
|
|
233
|
+
self.model.train()
|
|
234
|
+
return F.softmax(t, dim=-1)
|
|
235
|
+
|
|
236
|
+
@torch.no_grad()
|
|
237
|
+
def token_logps(self, text: str, max_len: int = 384):
|
|
238
|
+
"""The log-probability of each actual next token under the model as it is (every self and the present on)."""
|
|
239
|
+
ids = self.encode(text, max_len)
|
|
240
|
+
self.model.eval()
|
|
241
|
+
lp = F.log_softmax(self.model(ids).logits.float(), dim=-1)[0, :-1]
|
|
242
|
+
self.model.train()
|
|
243
|
+
tgt = ids[0, 1:]
|
|
244
|
+
return tgt.tolist(), lp.gather(-1, tgt.unsqueeze(-1)).squeeze(-1).tolist()
|
|
245
|
+
|
|
246
|
+
@torch.no_grad()
|
|
247
|
+
def perplexity(self, texts: Iterable[str], max_len: int = 384) -> float:
|
|
248
|
+
self.model.eval()
|
|
249
|
+
tot, n = 0.0, 0
|
|
250
|
+
for t in texts:
|
|
251
|
+
ids = self.encode(t, max_len)
|
|
252
|
+
if ids.shape[1] < 2:
|
|
253
|
+
continue
|
|
254
|
+
out = self.model(ids, labels=ids)
|
|
255
|
+
tot += out.loss.item() * (ids.shape[1] - 1); n += ids.shape[1] - 1
|
|
256
|
+
self.model.train()
|
|
257
|
+
return float(torch.exp(torch.tensor(tot / max(n, 1))))
|
|
258
|
+
|
|
259
|
+
@torch.no_grad()
|
|
260
|
+
def generate(self, prompt: str, max_new: int = 200, temperature: float = 0.7, top_p: float = 0.9, chat: Optional[bool] = None) -> str:
|
|
261
|
+
self.model.eval()
|
|
262
|
+
ids = self.prompt_ids(prompt, chat)
|
|
263
|
+
# greedy decoding passes None for the sampling knobs so a base's own generation_config (Qwen3 ships temperature 0.6,
|
|
264
|
+
# top_p 0.95, top_k 20) does not make transformers warn three times in the middle of a gate's quiet minutes
|
|
265
|
+
sample = ({"do_sample": True, "temperature": temperature, "top_p": top_p} if temperature > 0
|
|
266
|
+
else {"do_sample": False, "temperature": None, "top_p": None, "top_k": None})
|
|
267
|
+
out = self.model.generate(input_ids=ids, attention_mask=torch.ones_like(ids), max_new_tokens=max_new,
|
|
268
|
+
pad_token_id=self.tok.pad_token_id, **sample)
|
|
269
|
+
self.model.train()
|
|
270
|
+
return self.tok.decode(out[0, ids.shape[1]:], skip_special_tokens=True).strip()
|
|
271
|
+
|
|
272
|
+
def chat_ids(self, messages: list, chat: Optional[bool] = None) -> torch.Tensor:
|
|
273
|
+
"""A conversation (OpenAI-style messages: role system/user/assistant, content) as the model expects it: through the chat template
|
|
274
|
+
when the body reads chat, else as plain 'Role: content' lines ending in 'Assistant:' so a reply starts on its own line."""
|
|
275
|
+
chat = self.chat if chat is None else chat
|
|
276
|
+
msgs = [{"role": str(m.get("role", "user")), "content": str(m.get("content", ""))} for m in messages]
|
|
277
|
+
if chat and getattr(self.tok, "chat_template", None):
|
|
278
|
+
kw = {"add_generation_prompt": True, "return_tensors": "pt"}
|
|
279
|
+
try:
|
|
280
|
+
ids = self.tok.apply_chat_template(msgs, enable_thinking=False, **kw)
|
|
281
|
+
except TypeError:
|
|
282
|
+
ids = self.tok.apply_chat_template(msgs, **kw)
|
|
283
|
+
if not torch.is_tensor(ids):
|
|
284
|
+
ids = ids["input_ids"]
|
|
285
|
+
return ids.to(self.device)
|
|
286
|
+
text = "".join(f"{m['role'].capitalize()}: {m['content'].strip()}\n" for m in msgs) + "Assistant:"
|
|
287
|
+
return self.tok(text, return_tensors="pt").input_ids.to(self.device)
|
|
288
|
+
|
|
289
|
+
def _eos_ids(self) -> set:
|
|
290
|
+
"""Every id the base ends a reply with (a chat template's end-of-turn beside the tokenizer's eos)."""
|
|
291
|
+
ids = set()
|
|
292
|
+
for src in (getattr(self.model.generation_config, "eos_token_id", None), self.tok.eos_token_id):
|
|
293
|
+
if isinstance(src, int):
|
|
294
|
+
ids.add(src)
|
|
295
|
+
elif isinstance(src, (list, tuple)):
|
|
296
|
+
ids.update(int(x) for x in src)
|
|
297
|
+
return ids
|
|
298
|
+
|
|
299
|
+
def _generate_kw(self, ids: torch.Tensor, max_new: int, temperature: float, top_p: float, stop: Optional[list]) -> dict:
|
|
300
|
+
sample = ({"do_sample": True, "temperature": temperature, "top_p": top_p} if temperature > 0
|
|
301
|
+
else {"do_sample": False, "temperature": None, "top_p": None, "top_k": None})
|
|
302
|
+
kw = {"input_ids": ids, "attention_mask": torch.ones_like(ids), "max_new_tokens": max_new, "pad_token_id": self.tok.pad_token_id, **sample}
|
|
303
|
+
if stop:
|
|
304
|
+
kw.update(stop_strings=list(stop), tokenizer=self.tok) # generation halts at the first stop string (the string itself is in the text)
|
|
305
|
+
return kw
|
|
306
|
+
|
|
307
|
+
def _ended(self, new: torch.Tensor, max_new: int) -> bool:
|
|
308
|
+
"""Whether the reply ended by itself (an end token, or fewer tokens than allowed) rather than at the cap."""
|
|
309
|
+
return int(new.shape[0]) < max_new or (new.shape[0] > 0 and int(new[-1]) in self._eos_ids())
|
|
310
|
+
|
|
311
|
+
@torch.no_grad()
|
|
312
|
+
def generate_chat(self, messages: list, max_new: int = 200, temperature: float = 0.7, top_p: float = 0.9, chat: Optional[bool] = None,
|
|
313
|
+
stop: Optional[list] = None) -> tuple:
|
|
314
|
+
"""One reply to a conversation; returns (reply, prompt_tokens, completion_tokens, ended), `ended` False when the reply hit `max_new`.
|
|
315
|
+
`stop`: strings at which generation halts (the reply is returned up to and including the one met; the caller cuts it)."""
|
|
316
|
+
self.model.eval()
|
|
317
|
+
ids = self.chat_ids(messages, chat)
|
|
318
|
+
kw = self._generate_kw(ids, max_new, temperature, top_p, stop)
|
|
319
|
+
try:
|
|
320
|
+
out = self.model.generate(**kw)
|
|
321
|
+
except TypeError: # a transformers without stop_strings: generate the cap, the caller cuts
|
|
322
|
+
kw.pop("stop_strings", None); kw.pop("tokenizer", None)
|
|
323
|
+
out = self.model.generate(**kw)
|
|
324
|
+
self.model.train()
|
|
325
|
+
new = out[0, ids.shape[1]:]
|
|
326
|
+
return self.tok.decode(new, skip_special_tokens=True).strip(), int(ids.shape[1]), int(new.shape[0]), self._ended(new, max_new)
|
|
327
|
+
|
|
328
|
+
def stream_chat(self, messages: list, result: dict, max_new: int = 200, temperature: float = 0.7, top_p: float = 0.9,
|
|
329
|
+
chat: Optional[bool] = None, stop: Optional[list] = None):
|
|
330
|
+
"""`generate_chat` as a generator of text pieces, as the tokens come (transformers' TextIteratorStreamer, the generation in a
|
|
331
|
+
thread). When the generator is exhausted `result` holds reply, prompt_tokens, completion_tokens and ended, as `generate_chat` returns them."""
|
|
332
|
+
import threading
|
|
333
|
+
from transformers import TextIteratorStreamer
|
|
334
|
+
self.model.eval()
|
|
335
|
+
ids = self.chat_ids(messages, chat)
|
|
336
|
+
kw = self._generate_kw(ids, max_new, temperature, top_p, stop)
|
|
337
|
+
streamer = TextIteratorStreamer(self.tok, skip_prompt=True, skip_special_tokens=True, timeout=600.0)
|
|
338
|
+
kw["streamer"] = streamer
|
|
339
|
+
held: list = []
|
|
340
|
+
|
|
341
|
+
def work():
|
|
342
|
+
with torch.no_grad():
|
|
343
|
+
try:
|
|
344
|
+
held.append(self.model.generate(**kw))
|
|
345
|
+
except TypeError:
|
|
346
|
+
kw.pop("stop_strings", None); kw.pop("tokenizer", None)
|
|
347
|
+
held.append(self.model.generate(**kw))
|
|
348
|
+
except BaseException as e: # the streamer's end() is never reached: release the reader
|
|
349
|
+
held.append(e); streamer.end()
|
|
350
|
+
t = threading.Thread(target=work, daemon=True)
|
|
351
|
+
t.start()
|
|
352
|
+
pieces = []
|
|
353
|
+
try:
|
|
354
|
+
for piece in streamer:
|
|
355
|
+
if piece:
|
|
356
|
+
pieces.append(piece)
|
|
357
|
+
yield piece
|
|
358
|
+
finally:
|
|
359
|
+
t.join(timeout=600.0)
|
|
360
|
+
self.model.train()
|
|
361
|
+
if held and isinstance(held[0], BaseException):
|
|
362
|
+
raise held[0]
|
|
363
|
+
out = held[0]
|
|
364
|
+
new = out[0, ids.shape[1]:]
|
|
365
|
+
result.update(reply="".join(pieces).strip(), prompt_tokens=int(ids.shape[1]), completion_tokens=int(new.shape[0]), ended=self._ended(new, max_new))
|
|
366
|
+
|
|
367
|
+
def prompt_ids(self, prompt: str, chat: Optional[bool] = None, system: Optional[str] = None) -> torch.Tensor:
|
|
368
|
+
"""A prompt as the model expects it: through the chat template when the body reads chat (`chat` overrides), else
|
|
369
|
+
the raw text ending in a newline, so a reply or a scored opener starts a new line as it would under a template."""
|
|
370
|
+
chat = self.chat if chat is None else chat
|
|
371
|
+
if chat and getattr(self.tok, "chat_template", None):
|
|
372
|
+
msgs = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
|
|
373
|
+
kw = {"add_generation_prompt": True, "return_tensors": "pt"}
|
|
374
|
+
try:
|
|
375
|
+
ids = self.tok.apply_chat_template(msgs, enable_thinking=False, **kw)
|
|
376
|
+
except TypeError:
|
|
377
|
+
ids = self.tok.apply_chat_template(msgs, **kw)
|
|
378
|
+
if not torch.is_tensor(ids):
|
|
379
|
+
ids = ids["input_ids"]
|
|
380
|
+
return ids.to(self.device)
|
|
381
|
+
text = (system.rstrip() + "\n\n" if system else "") + prompt.rstrip() + "\n"
|
|
382
|
+
return self.tok(text, return_tensors="pt").input_ids.to(self.device)
|
|
383
|
+
|
|
384
|
+
def save_present(self, path: str) -> str:
|
|
385
|
+
os.makedirs(path, exist_ok=True)
|
|
386
|
+
self.model.save_pretrained(path, selected_adapters=["present"])
|
|
387
|
+
return os.path.join(path, "present")
|
|
388
|
+
|
|
389
|
+
def finish_fold(self, name: str) -> None:
|
|
390
|
+
"""After `load_self(saved, name)`: the loaded adapter joins the stack as the newest self, the present is dropped and a
|
|
391
|
+
fresh one is born on top. Seed torch before calling when the fresh present should be reproducible."""
|
|
392
|
+
self._drop_present()
|
|
393
|
+
self.selves.append(name)
|
|
394
|
+
self.activate()
|
|
395
|
+
|
|
396
|
+
def fold_present(self, saved_dir: str) -> str:
|
|
397
|
+
"""The present becomes the next frozen self, exactly; a fresh present is born on top. Returns the new self's name."""
|
|
398
|
+
name = self.load_self(saved_dir, f"g{len(self.selves) + 1:03d}")
|
|
399
|
+
self.finish_fold(name)
|
|
400
|
+
return name
|
|
401
|
+
|
|
402
|
+
def reset_present(self) -> None:
|
|
403
|
+
"""Drop the present's weights and begin a fresh one (a discard). Seed torch before calling for reproducibility."""
|
|
404
|
+
self._drop_present()
|
|
405
|
+
self.activate()
|
|
406
|
+
|
|
407
|
+
def _drop_present(self) -> None:
|
|
408
|
+
"""Delete the present (turned off first, so peft does not warn) and bear a fresh one."""
|
|
409
|
+
set_active(self.model, self.selves, trainable=[])
|
|
410
|
+
self.model.delete_adapter("present")
|
|
411
|
+
self._born_present(self.lora_config())
|
|
412
|
+
|
|
413
|
+
def drop_self(self, name: str) -> None:
|
|
414
|
+
"""Take the newest self off the model (a discarded life); the present stays."""
|
|
415
|
+
if not self.selves or self.selves[-1] != name:
|
|
416
|
+
raise ValueError(f"{name} is not the newest self ({self.selves[-1:] or 'none'})")
|
|
417
|
+
self.model.delete_adapter(name); self.selves.pop(); self.activate()
|