Support fitting lenses on any dataset and equal-parts mixes

The fit corpus was limited to three hardcoded choices (wikitext, Semantic-Harmless, mixed), and any other id was rejected. Now any HuggingFace dataset id works, and any number of them can be ticked to fit on an equal-parts mix, shuffled.

n_prompts now counts training SEQUENCES (what the fit iterates over) instead of source rows: each dataset is packed up to its quota, so the number entered is exactly what runs, regardless of the dataset. The fixed dropdown becomes a checkable dataset library persisted in localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Extraltodeus
2026-07-14 05:08:52 +02:00
co-authored by Claude Opus 4.8
parent daeb127651
commit d9773394e3
4 changed files with 180 additions and 77 deletions
+5 -3
View File
@@ -211,9 +211,11 @@ Gemma — the error shows up in the UI if so.)
### 6. Fit your own lens ### 6. Fit your own lens
In **Fit** (model unloaded, VRAM free), fit a lens on streamed WikiText across one In **Fit** (model unloaded, VRAM free), fit a lens across one or more GPUs on the
or more GPUs, with per-prompt checkpoints (stop/resume without loss) and weighted corpus of your choice: tick any HuggingFace dataset by id (WikiText by default),
merging. Metadata is written to `lenses/<name>/meta.json`. or tick several to fit on an equal-parts mix. Per-prompt checkpoints give
stop/resume without loss, and multi-GPU slices are merged by weighted average.
Metadata is written to `lenses/<name>/meta.json`.
### CLI (no UI) ### CLI (no UI)
+2 -2
View File
@@ -175,7 +175,7 @@ class FitRequest(BaseModel):
dtype: str = "bf16" dtype: str = "bf16"
quant: str | None = None quant: str | None = None
n_prompts: int = 100 n_prompts: int = 100
dataset: str = fitting.DATASET_WIKITEXT # or DATASET_HARMLESS, or "mixed" datasets: list[str] = [fitting.DATASET_WIKITEXT] # any HF ids; several = equal-parts mix
devices: list[str] = ["cuda:0"] devices: list[str] = ["cuda:0"]
name: str | None = None name: str | None = None
dim_batch: int | None = None dim_batch: int | None = None
@@ -811,7 +811,7 @@ def api_fit(req: FitRequest):
n_prompts=req.n_prompts, n_prompts=req.n_prompts,
dtype=req.dtype, dtype=req.dtype,
quant=req.quant, quant=req.quant,
dataset=req.dataset, datasets=req.datasets,
devices=req.devices, devices=req.devices,
name=req.name, name=req.name,
dim_batch=req.dim_batch, dim_batch=req.dim_batch,
+115 -53
View File
@@ -1,5 +1,6 @@
import hashlib import hashlib
import json import json
import re
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -14,65 +15,125 @@ from core.gpus import gpu_stats
FITS_DIR = config.DATA_DIR / "fits" FITS_DIR = config.DATA_DIR / "fits"
WORKER = config.ROOT / "scripts" / "fit_worker.py" WORKER = config.ROOT / "scripts" / "fit_worker.py"
# Fit corpora. "mixed" = both, equal parts (rounded to the nearest prompt). # Fit corpus. Any HuggingFace dataset id works as-is: wikitext is the default
# and keeps a dedicated streamed path, every other id goes through the generic
# loader below. Several ids = an equal-parts mix.
DATASET_WIKITEXT = "Salesforce/wikitext-103-raw-v1" DATASET_WIKITEXT = "Salesforce/wikitext-103-raw-v1"
DATASET_HARMLESS = "heretic-org/Semantic-Harmless"
FIT_DATASETS = (DATASET_WIKITEXT, DATASET_HARMLESS, "mixed") # Seed shared by the row sampling and the mix shuffle: a continued fit that asks
# for skip+n rows deterministically extends the sequence it drew the first n
# from (sample(skip+n) then drop the head).
_SAMPLE_SEED = 1729
def _load_corpus(dataset, n, skip=0): def _slug(dataset):
"""``n`` prompts from ``dataset``, skipping the first ``skip`` picks """Short, filename-safe tag from a dataset id's last path segment, e.g.
(continue-from: the new prompts must not overlap the base lens's). ``heretic-org/Semantic-Harmless`` -> ``semantic-harmless``."""
tail = dataset.rstrip("/").split("/")[-1].lower()
return re.sub(r"[^a-z0-9]+", "-", tail).strip("-")[:24] or "dataset"
wikitext keeps the historical behavior (first records ≥600 chars, streamed).
Semantic-Harmless is a small instruct set (~416 one-line prompts): we draw a
seeded random sample — sample(skip+n) then drop the head, so a continued fit
extends the same sequence — and PACK the picks into ~350-char sequences
(median prompt ≈ 10 tokens, and jlens skips the first 16 positions of every
sequence as attention sinks: unpacked, almost every pick would be dropped as
"too short"). ``n``/``skip`` count SOURCE prompts, not packs. "mixed" takes
equal parts of both (n odd: the extra prompt goes to wikitext) and shuffles
the union so multi-GPU slices stay mixed."""
if dataset == "mixed":
import random
n_wiki = (n + 1) // 2 def _text_column(features):
s_wiki = (skip + 1) // 2 """Column to fit on: prefer ``text``, else the first string-valued column."""
prompts = _load_corpus(DATASET_WIKITEXT, n_wiki, s_wiki) from datasets import Value
prompts += _load_corpus(DATASET_HARMLESS, n - n_wiki, skip // 2)
random.Random(1729).shuffle(prompts)
return prompts
if dataset == DATASET_HARMLESS:
import random
from datasets import load_dataset if "text" in features:
return "text"
texts = [r["text"] for r in load_dataset(DATASET_HARMLESS, split="train")] for name, feat in features.items():
if skip + n > len(texts): if isinstance(feat, Value) and feat.dtype == "string":
return name
raise ValueError( raise ValueError(
f"{DATASET_HARMLESS} has {len(texts)} prompts, " "dataset exposes no text column to fit on "
f"{skip + n} requested (continue included) — lower n_prompts" f"(columns: {', '.join(features) or 'none'})"
) )
picks = random.Random(1729).sample(texts, skip + n)[skip:]
def _pack(texts, count, target=350):
"""Pack ``texts`` into ~``target``-char sequences, stopping as soon as
``count`` sequences are ready. jlens skips the first 16 positions of every
sequence as attention sinks, so short unpacked prompts would almost all be
dropped as too short. Returns fewer than ``count`` only if ``texts`` runs
out (the caller decides whether that is an error)."""
packs, cur = [], "" packs, cur = [], ""
for text in picks: for text in texts:
text = (text or "").strip()
if not text:
continue
cur = f"{cur}\n\n{text}" if cur else text cur = f"{cur}\n\n{text}" if cur else text
if len(cur) >= 350: if len(cur) >= target:
packs.append(cur) packs.append(cur)
cur = "" cur = ""
if cur: if len(packs) >= count:
# a lone sub-16-token tail would be skipped by jlens anyway: fold it return packs
# into the previous pack instead of losing it if cur and len(packs) < count:
if packs and len(cur) < 120: # trailing remainder: keep it so a just-large-enough dataset still fills
packs[-1] += "\n\n" + cur # its quota (jlens tolerates a slightly-short final sequence)
else:
packs.append(cur) packs.append(cur)
return packs return packs
def _load_split(dataset):
"""``dataset``'s ``train`` split, or its first split if it has no ``train``."""
from datasets import load_dataset
try:
return load_dataset(dataset, split="train")
except ValueError:
dd = load_dataset(dataset)
return dd[next(iter(dd))]
def _load_one(dataset, n, skip):
"""``n`` training SEQUENCES from a single ``dataset`` id, skipping the first
``skip`` (continue-from: the new sequences must not overlap the base lens's).
wikitext keeps its historical path (first records >=600 chars, streamed —
one record already is one sequence). Any other HF dataset is loaded whole,
its text column shuffled with a fixed seed, then PACKED into ~350-char
sequences until skip+n are ready (median instruct prompt ~10 tokens, so
several rows per sequence). ``n``/``skip`` count OUTPUT sequences, so the
number the user asks for is exactly what the fit iterates over — not source
rows, whose count varies per dataset."""
if n <= 0:
return []
if dataset == DATASET_WIKITEXT:
from jlens.examples import load_wikitext_prompts from jlens.examples import load_wikitext_prompts
# load skip + n then keep the tail: the new prompts don't overlap # load skip + n then keep the tail: the new sequences don't overlap the
# those of the base lens # base lens's
return load_wikitext_prompts(skip + n)[skip:] return load_wikitext_prompts(skip + n)[skip:]
import random
ds = _load_split(dataset)
col = _text_column(ds.features)
texts = [r[col] for r in ds]
random.Random(_SAMPLE_SEED).shuffle(texts)
packs = _pack(texts, skip + n)
if len(packs) < skip + n:
raise ValueError(
f"{dataset}: {len(texts)} rows pack into only {len(packs)} sequences, "
f"{skip + n} requested — lower n_prompts"
)
return packs[skip:skip + n]
def _load_corpus(datasets, n, skip=0):
"""``n`` training SEQUENCES drawn from ``datasets`` (a list of HF dataset
ids). A single id loads that dataset; several are mixed in EQUAL parts — n
and skip are each split across them (the first datasets take the rounding
remainder) and the union is shuffled so multi-GPU slices stay mixed. Because
the count is in sequences, ``n`` is exactly what the fit iterates over."""
datasets = list(datasets)
if len(datasets) == 1:
return _load_one(datasets[0], n, skip)
import random
k = len(datasets)
prompts = []
for i, ds in enumerate(datasets):
prompts += _load_one(ds, n // k + int(i < n % k), skip // k + int(i < skip % k))
random.Random(_SAMPLE_SEED).shuffle(prompts)
return prompts
def _default_dim_batch(device): def _default_dim_batch(device):
"""Default dim_batch scaled to the device's VRAM. """Default dim_batch scaled to the device's VRAM.
@@ -102,14 +163,15 @@ class FitManager:
def start(self, *, model_id, source, n_prompts=100, dtype="bf16", quant=None, def start(self, *, model_id, source, n_prompts=100, dtype="bf16", quant=None,
devices=("cuda:0",), name=None, dim_batch=None, devices=("cuda:0",), name=None, dim_batch=None,
max_seq_len=128, source_layers=None, model_revision=None, max_seq_len=128, source_layers=None, model_revision=None,
continue_from=None, dataset=DATASET_WIKITEXT): continue_from=None, datasets=(DATASET_WIKITEXT,)):
with self._lock: with self._lock:
if self.state.get("state") == "running": if self.state.get("state") == "running":
raise ValueError("a fitting is already in progress") raise ValueError("a fitting is already in progress")
if not devices: if not devices:
raise ValueError("at least one device required") raise ValueError("at least one device required")
if dataset not in FIT_DATASETS: datasets = [d.strip() for d in datasets if d and d.strip()]
raise ValueError(f"unknown dataset: {dataset} (choices: {', '.join(FIT_DATASETS)})") if not datasets:
raise ValueError("at least one dataset required")
skip_prompts = 0 skip_prompts = 0
base_lens = None base_lens = None
if continue_from: if continue_from:
@@ -120,10 +182,10 @@ class FitManager:
source_layers = list(base_lens.source_layers) source_layers = list(base_lens.source_layers)
if name is None: if name is None:
base = model_id.split("/")[-1] base = model_id.split("/")[-1]
if dataset == "mixed": if len(datasets) > 1:
base += "_mixed" base += "_mixed"
elif dataset == DATASET_HARMLESS: elif datasets[0] != DATASET_WIKITEXT:
base += "_harmless" base += "_" + _slug(datasets[0])
total = n_prompts + skip_prompts total = n_prompts + skip_prompts
name = f"{base}_n{total}" if continue_from else f"{base}_n{n_prompts}" name = f"{base}_n{total}" if continue_from else f"{base}_n{n_prompts}"
params = { params = {
@@ -133,7 +195,7 @@ class FitManager:
"dtype": dtype, "dtype": dtype,
"quant": quant, "quant": quant,
"n_prompts": n_prompts, "n_prompts": n_prompts,
"dataset": dataset, "datasets": datasets,
"devices": list(devices), "devices": list(devices),
"dim_batch": dim_batch, "dim_batch": dim_batch,
"max_seq_len": max_seq_len, "max_seq_len": max_seq_len,
@@ -176,7 +238,7 @@ class FitManager:
prompts = json.loads(corpus_path.read_text(encoding="utf-8")) prompts = json.loads(corpus_path.read_text(encoding="utf-8"))
else: else:
prompts = _load_corpus( prompts = _load_corpus(
params.get("dataset", DATASET_WIKITEXT), params.get("datasets", [DATASET_WIKITEXT]),
params["n_prompts"], params["n_prompts"],
params.get("skip_prompts", 0), params.get("skip_prompts", 0),
) )
@@ -299,9 +361,9 @@ class FitManager:
"quant": params["quant"], "quant": params["quant"],
"n_prompts": merged.n_prompts, "n_prompts": merged.n_prompts,
"corpus": ( "corpus": (
f"mixed: {DATASET_WIKITEXT} + {DATASET_HARMLESS} (equal parts)" "mixed: " + " + ".join(params["datasets"]) + " (equal parts)"
if params.get("dataset") == "mixed" if len(params["datasets"]) > 1
else params.get("dataset", DATASET_WIKITEXT) else params["datasets"][0]
), ),
"max_seq_len": params["max_seq_len"], "max_seq_len": params["max_seq_len"],
"devices": params["devices"], "devices": params["devices"],
+49 -10
View File
@@ -195,7 +195,34 @@ export default function App() {
const [fitModel, setFitModel] = useState('') const [fitModel, setFitModel] = useState('')
const [fitN, setFitN] = useState(100) const [fitN, setFitN] = useState(100)
const [fitDataset, setFitDataset] = useState('Salesforce/wikitext-103-raw-v1') // fit corpus library: tick one or several; several = mixed in equal parts.
// persisted so the user's added datasets survive a refresh.
const [fitDatasets, setFitDatasets] = useState(() => {
try {
const saved = JSON.parse(localStorage.getItem('jlens_fit_datasets') || 'null')
const restored = Array.isArray(saved)
? saved.filter((d) => d && d.id).map((d) => ({ id: String(d.id), on: !!d.on }))
: []
if (restored.length) return restored
} catch { /* ignore corrupt storage */ }
return [
{ id: 'Salesforce/wikitext-103-raw-v1', on: true },
{ id: 'heretic-org/Semantic-Harmless', on: false },
]
})
useEffect(() => {
localStorage.setItem('jlens_fit_datasets', JSON.stringify(fitDatasets))
}, [fitDatasets])
const [fitDatasetInput, setFitDatasetInput] = useState('')
const addFitDataset = () => {
const id = fitDatasetInput.trim()
if (!id) return
setFitDatasets((prev) =>
prev.some((d) => d.id === id)
? prev.map((d) => (d.id === id ? { ...d, on: true } : d))
: [...prev, { id, on: true }])
setFitDatasetInput('')
}
const [fitQuant, setFitQuant] = useState('') const [fitQuant, setFitQuant] = useState('')
const [fitDevices, setFitDevices] = useState([]) const [fitDevices, setFitDevices] = useState([])
const [fitDimBatch, setFitDimBatch] = useState('') const [fitDimBatch, setFitDimBatch] = useState('')
@@ -1287,15 +1314,27 @@ export default function App() {
<div className="row"><label>name</label> <div className="row"><label>name</label>
<input type="text" placeholder="(auto: model_nN)" value={fitName} onChange={(e) => setFitName(e.target.value)} /> <input type="text" placeholder="(auto: model_nN)" value={fitName} onChange={(e) => setFitName(e.target.value)} />
</div> </div>
<div className="row"><label title="number of corpus prompts (existing lenses were made with n=100 unless marked _nNNN)">prompts</label> <div className="row"><label title="number of training sequences the fit iterates over — what you set is exactly what runs (existing lenses were made with n=100 unless marked _nNNN)">sequences</label>
<input type="number" min="4" step="1" value={fitN} onChange={(e) => setFitN(e.target.value)} /> <input type="number" min="4" step="1" value={fitN} onChange={(e) => setFitN(e.target.value)} />
</div> </div>
<div className="row"><label title="fit corpus. mixed = both datasets in equal parts (rounded to the nearest prompt), shuffled">dataset</label> <div className="row"><label title="fit corpus. Tick one or several HuggingFace datasets; several ticked = mixed in equal parts (rounded to the nearest sequence), shuffled">datasets</label>
<select value={fitDataset} onChange={(e) => setFitDataset(e.target.value)}> <span style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
<option value="Salesforce/wikitext-103-raw-v1">Salesforce/wikitext-103-raw-v1</option> {fitDatasets.map((d, i) => (
<option value="heretic-org/Semantic-Harmless">heretic-org/Semantic-Harmless</option> <label key={d.id} style={{ width: 'auto', display: 'flex', alignItems: 'center', gap: 6 }} title={d.id}>
<option value="mixed">mixed (50/50)</option> <input type="checkbox" checked={d.on}
</select> onChange={(e) => setFitDatasets(fitDatasets.map((x, j) => j === i ? { ...x, on: e.target.checked } : x))} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.id}</span>
<button className="linkbtn" title="remove from the list (does not delete anything on disk)"
onClick={() => setFitDatasets(fitDatasets.filter((_, j) => j !== i))}></button>
</label>
))}
<span style={{ display: 'flex', gap: 6 }}>
<input type="text" placeholder="org/dataset (HuggingFace id)" value={fitDatasetInput}
onChange={(e) => setFitDatasetInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addFitDataset() } }} />
<button onClick={addFitDataset} title="add this dataset to the list">+</button>
</span>
</span>
</div> </div>
<div className="row"><label>quant</label> <div className="row"><label>quant</label>
<select value={fitQuant} onChange={(e) => setFitQuant(e.target.value)} disabled={!!fitContinue}> <select value={fitQuant} onChange={(e) => setFitQuant(e.target.value)} disabled={!!fitContinue}>
@@ -1347,7 +1386,7 @@ export default function App() {
)} )}
<button <button
className="primary" className="primary"
disabled={(!fitModel && !fitContinue) || !fitDevices.length || !!loadedId || !!busy} disabled={(!fitModel && !fitContinue) || !fitDevices.length || !fitDatasets.some((d) => d.on) || !!loadedId || !!busy}
onClick={async () => { onClick={async () => {
try { try {
const layers = [] const layers = []
@@ -1361,7 +1400,7 @@ export default function App() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model_id: fitModel, n_prompts: +fitN, quant: fitQuant || null, model_id: fitModel, n_prompts: +fitN, quant: fitQuant || null,
dataset: fitDataset, datasets: fitDatasets.filter((d) => d.on).map((d) => d.id),
name: fitName.trim() || null, devices: fitDevices, name: fitName.trim() || null, devices: fitDevices,
dim_batch: fitDimBatch ? +fitDimBatch : null, dim_batch: fitDimBatch ? +fitDimBatch : null,
max_seq_len: +fitMaxSeq, max_seq_len: +fitMaxSeq,