fela-moderator / train.py
itstheraj's picture
initial commit
4751195
Raw
History Blame Contribute Delete
13.3 kB
from __future__ import annotations
import argparse
import json
import os
import time
import torch
import torch.nn as nn
from safetensors.torch import save_file
from data import assemble, licenses
from data.bio import PAD_ID, encode_with_offsets
from data.taxonomy import JIGSAW_LABELS, N_PII_TAGS, TAXONOMY
from modeling import FELAModerationV2, ModerationConfig
from fela_server.train import TrainCtx, chunk_train
def encode_depth(model, ids, mask, depth):
x = model.tok_emb(ids)
kpm = mask == 0
for blk in model.blocks[:depth]:
x = blk(x, kpm)
return model.norm(x)
def head_logits(model, x, mask, task):
if task == "pii":
return model.pii_head(x)
pooled = model._pool(x, mask)
heads = {"taxonomy": model.tax_head, "jigsaw": model.jig_head, **model._clf_tasks}
return heads[task](pooled)
def masked_bce(bce, logits, targets, tmask, posw=1.0):
loss = bce(logits, targets)
w = tmask * (1.0 + (posw - 1.0) * targets)
return (loss * w).sum() / w.sum().clamp(min=1.0)
def _ckpt_uri_parts(uri):
b, _, k = uri[5:].partition("/")
return (b, k)
def ckpt_save(model, uri):
import io
import boto3
buf = io.BytesIO()
torch.save({k: v.cpu() for k, v in model.state_dict().items()}, buf)
b, k = _ckpt_uri_parts(uri)
boto3.client("s3").put_object(Bucket=b, Key=k, Body=buf.getvalue())
def ckpt_load(model, uri):
import io
import boto3
from botocore.exceptions import ClientError
b, k = _ckpt_uri_parts(uri)
try:
body = boto3.client("s3").get_object(Bucket=b, Key=k)["Body"].read()
except ClientError:
return False
model.load_state_dict(torch.load(io.BytesIO(body), map_location="cpu"))
return True
def make_tox_batch(examples, max_len):
seqs = [encode_with_offsets(ex["text"], max_len)[0] for ex in examples]
length = min(max((len(s) for s in seqs)), max_len)
b = len(seqs)
ids = torch.full((b, length), PAD_ID, dtype=torch.long)
am = torch.zeros((b, length), dtype=torch.long)
for i, s in enumerate(seqs):
n = min(len(s), length)
ids[i, :n] = torch.tensor(s[:n])
am[i, :n] = 1
labels = torch.tensor([ex["labels"] for ex in examples], dtype=torch.float)
lmask = torch.tensor([ex["mask"] for ex in examples], dtype=torch.float)
return (ids, am, labels, lmask)
def iter_tox_batches(loader_fn, batch_size, max_len, cap):
yield from iter_clf_batches(loader_fn(max_rows=cap), batch_size, max_len)
def iter_clf_batches(stream, batch_size, max_len):
buf = []
for ex in stream:
buf.append(ex)
if len(buf) >= batch_size:
yield make_tox_batch(buf, max_len)
buf = []
if buf:
yield make_tox_batch(buf, max_len)
def _combined(real_fn, synth_fn, real_cap, n_synth, seed=0):
import random
rng = random.Random(seed)
pools = []
if real_fn is not None:
pools.append(real_fn(max_rows=real_cap))
if synth_fn is not None:
pools.append(synth_fn(n_synth, seed=seed))
while pools:
src = rng.choice(pools)
try:
yield next(src)
except StopIteration:
pools.remove(src)
_AUX_HEADS = [
("spam", "spam", "load_spam_examples", "synth_spam"),
("jailbreak", "jailbreak", "load_jailbreak_examples", "synth_jailbreak"),
("nsfw", "nsfw", "load_nsfw_examples", "synth_nsfw"),
("identity", "identity", "load_identity_examples", None),
]
def quantize_int8(state):
out, scales = ({}, {})
for k, v in state.items():
if v.dtype.is_floating_point and v.dim() >= 2:
amax = v.abs().max().clamp(min=1e-08)
s = amax / 127.0
out[k] = (v / s).round().clamp(-127, 127).to(torch.int8)
scales[k] = float(s)
else:
out[k] = v
return (out, scales)
def subset_state(state, depth):
keep = {}
for k, v in state.items():
if k.startswith("blocks."):
if int(k.split(".")[1]) < depth:
keep[k] = v
else:
keep[k] = v
return keep
def _bytes_int8(state):
return sum(
(
v.numel() * (1 if v.dtype == torch.int8 else v.element_size())
for v in state.values()
)
)
def export(model, out_dir, n_tax, nested_depth):
os.makedirs(out_dir, exist_ok=True)
state = {k: v.detach().cpu().float() for k, v in model.state_dict().items()}
save_file(state, os.path.join(out_dir, "model.safetensors"))
manifest = {
"n_tax": n_tax,
"n_pii_tags": N_PII_TAGS,
"taxonomy": TAXONOMY,
"jigsaw": JIGSAW_LABELS,
"ship": "weights_only",
"tiers": [],
}
def write_tier(name, st):
q, sc = quantize_int8(st)
save_file(
{k: v for k, v in q.items()},
os.path.join(out_dir, f"tier_{name}_int8.safetensors"),
)
with open(os.path.join(out_dir, f"tier_{name}_scales.json"), "w") as f:
json.dump(sc, f)
manifest["tiers"].append(
{
"tier": name,
"int8_bytes": _bytes_int8(q),
"int8_mb": round(_bytes_int8(q) / 1000000.0, 2),
}
)
if 0 < nested_depth < len(model.blocks):
write_tier("small", subset_state(state, nested_depth))
write_tier("full", state)
with open(os.path.join(out_dir, "manifest.json"), "w") as f:
json.dump(manifest, f, indent=2)
with open(os.path.join(out_dir, "NOTICE.txt"), "w") as f:
f.write(licenses.notice_text())
return manifest
def _saver_for(n_tax, nested_depth):
def _save(model, artifact, **meta):
manifest = export(model, artifact or "./retrained", n_tax, nested_depth)
print("EXPORT:", json.dumps(manifest["tiers"]))
if meta:
print("METRICS:", json.dumps({k: v for k, v in meta.items()}, default=str))
return _save
def _train_core(ctx, args):
torch.manual_seed(ctx.rank)
torch.set_num_threads(
int(os.environ.get("OMP_NUM_THREADS", torch.get_num_threads()))
)
cfg = ModerationConfig(n_pii_tags=N_PII_TAGS)
model = FELAModerationV2(cfg, n_tax=len(TAXONOMY))
if ctx.rank == 0:
print(
f"params={model.param_count() / 1000000.0:.2f}M n_tax={len(TAXONOMY)} pii_tags={N_PII_TAGS} world={ctx.world} threads={torch.get_num_threads()}"
)
resume_uri = os.environ.get("MOD_RESUME_S3")
if resume_uri and ckpt_load(model, resume_uri):
print(f"[rank{ctx.rank}] resumed from {resume_uri}")
ckpt_uri = os.environ.get("MOD_CKPT_S3")
ckpt_every = int(os.environ.get("MOD_CKPT_EVERY", "300"))
posw = float(os.environ.get("MOD_POSWEIGHT", "5.0"))
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.01)
bce = nn.BCEWithLogitsLoss(reduction="none")
ce = nn.CrossEntropyLoss(ignore_index=-100)
depths = [len(model.blocks)] + (
[args.nested_depth] if 0 < args.nested_depth < len(model.blocks) else []
)
use_tox = not args.smoke and (not args.no_toxicity)
core = []
aux = []
if use_tox:
import importlib
from data import toxicity
ml = max(args.context_lengths)
core.append(
(
"taxonomy",
iter_tox_batches(
toxicity.load_taxonomy_examples, args.bs, ml, args.tox_cap
),
)
)
core.append(
(
"jigsaw",
iter_tox_batches(
toxicity.load_jigsaw_examples, args.bs, ml, args.tox_cap
),
)
)
n_syn = max(1, args.n_synth // ctx.world)
for task, modname, real_attr, synth_attr in _AUX_HEADS:
try:
mod = importlib.import_module(f"data.{modname}")
real_fn = getattr(mod, real_attr, None)
synth_fn = getattr(mod, synth_attr, None) if synth_attr else None
stream = _combined(
real_fn, synth_fn, args.tox_cap, n_syn, seed=ctx.rank
)
aux.append((task, iter_clf_batches(stream, args.bs, ml)))
except Exception as e:
print(f"[aux] {task} unavailable: {e!r}")
step, toks, last, t0 = (0, 0, float("nan"), time.time())
model.train()
for ep in range(args.epochs):
pii = assemble.iter_pii_batches(
args.bs,
args.context_lengths,
max(1, args.n_synth // ctx.world),
seed=ep * ctx.world + ctx.rank,
use_real=not args.smoke and args.use_real,
)
for ids, mask, tags in pii:
loss = torch.zeros(())
for d in depths:
x = encode_depth(model, ids, mask, d)
loss = loss + ce(
head_logits(model, x, mask, "pii").reshape(-1, N_PII_TAGS),
tags.reshape(-1),
)
for cname, cit in core:
cbatch = next(cit, None)
if cbatch is not None:
cids, cam, clab, cmsk = cbatch
cx = encode_depth(model, cids, cam, len(model.blocks))
loss = loss + masked_bce(
bce, head_logits(model, cx, cam, cname), clab, cmsk, posw
)
if aux:
name, it = aux[step % len(aux)]
batch = next(it, None)
if batch is not None:
tids, tam, tlab, tmsk = batch
tx = encode_depth(model, tids, tam, len(model.blocks))
loss = loss + masked_bce(
bce, head_logits(model, tx, tam, name), tlab, tmsk, posw
)
opt.zero_grad()
loss.backward()
opt.step()
ctx.step(model)
step += 1
toks += ids.numel()
last = float(loss.item())
if ctx.rank == 0 and (step % 20 == 0 or args.smoke):
dt = time.time() - t0
print(
f"ep{ep} step{step} loss={last:.4f} tok/s={toks / max(dt, 1e-09):,.0f}"
)
if ctx.rank == 0 and ckpt_uri and (step % ckpt_every == 0):
ckpt_save(model, ckpt_uri)
if args.steps and step >= args.steps:
break
if args.steps and step >= args.steps:
break
dt = time.time() - t0
ctx.log(
steps=step,
final_loss=last,
wall_s=round(dt, 1),
tok_per_s=round(toks / max(dt, 1e-09), 1),
)
ctx.save(model)
if ctx.rank == 0:
print("licenses:", [s[0] for s in licenses.SOURCES])
print(licenses.HUMAN_SIGNOFF)
return {
"steps": step,
"final_loss": last,
"tok_per_s": round(toks / max(dt, 1e-09), 1),
}
def _hp_from_env():
e = os.environ.get
return argparse.Namespace(
epochs=int(e("MOD_EPOCHS", "6")),
steps=int(e("MOD_STEPS", "0")),
bs=int(e("MOD_BS", "32")),
lr=float(e("MOD_LR", "3e-4")),
context_lengths=[
int(x) for x in e("MOD_CTXLENS", "128,512,2048,8192").split(",")
],
nested_depth=int(e("MOD_NESTED", "4")),
n_synth=int(e("MOD_NSYNTH", "50000")),
tox_cap=int(e("MOD_TOXCAP", "200000")),
use_real=e("MOD_USEREAL", "1") == "1",
no_toxicity=e("MOD_NOTOX", "0") == "1",
smoke=e("MOD_SMOKE", "0") == "1",
)
@chunk_train(
name="moderator",
sync_every=int(os.environ.get("MOD_SYNC", "50")),
artifact=os.environ.get("MOD_ARTIFACT", "./retrained"),
saver=_saver_for(len(TAXONOMY), int(os.environ.get("MOD_NESTED", "4"))),
)
def train_job(ctx: TrainCtx = TrainCtx()):
return _train_core(ctx, _hp_from_env())
def train(args):
ctx = TrainCtx(
0,
1,
sync_every=1,
artifact=args.out,
saver=_saver_for(len(TAXONOMY), args.nested_depth),
)
_train_core(ctx, args)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="./retrained")
ap.add_argument("--epochs", type=int, default=6)
ap.add_argument(
"--steps", type=int, default=0, help="hard cap on optimizer steps (0=off)"
)
ap.add_argument("--bs", type=int, default=32)
ap.add_argument("--lr", type=float, default=0.0003)
ap.add_argument(
"--context-lengths", type=int, nargs="+", default=[128, 512, 2048, 8192]
)
ap.add_argument(
"--nested-depth", type=int, default=4, help="small-tier block depth (0=off)"
)
ap.add_argument("--n-synth", type=int, default=50000)
ap.add_argument("--tox-cap", type=int, default=200000)
ap.add_argument(
"--use-real",
action="store_true",
help="include real PII datasets (needs network)",
)
ap.add_argument("--no-toxicity", action="store_true")
ap.add_argument("--smoke", action="store_true", help="offline end-to-end check")
args = ap.parse_args()
if args.smoke:
args.epochs, args.steps, args.n_synth = (1, 6, 200)
args.bs, args.context_lengths = (4, [128, 512])
train(args)