npu-forge / modal /train_ear_head.py
LJTSG's picture
v0.3: NPU-validated voice ear (111KB head + trainer + runtime), start.bat menu, 47.8 tok/s baseline, snag #8
7532a11 verified
Raw
History Blame Contribute Delete
3.69 kB
"""npu-forge-ear: train a tiny voice-verifier HEAD on EmbeddingGemma-300m
embeddings. At runtime the embeddings come from FLM's NPU (embed-gemma:300m,
unquantized) and the head is ~20KB of JSON applied in plain JS — a fully
NPU-accelerated 'trained ear' with zero extra runtimes.
modal run train_ear_head.py
"""
import modal
app = modal.App("npu-forge-ear")
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("torch==2.5.1", "transformers==4.56.2", "sentence-transformers==5.1.0", "numpy", "huggingface_hub")
.add_local_dir("C:/Users/Forgemind/Desktop/voice-harness/scratch/verifier-data", remote_path="/data")
)
vol = modal.Volume.from_name("npu-forge-out", create_if_missing=True)
@app.function(image=image, gpu="T4", timeout=2400, volumes={"/out": vol}, secrets=[modal.Secret.from_name("huggingface-token")])
def train():
import json, numpy as np, torch
from sentence_transformers import SentenceTransformer
def load(p):
return [json.loads(l) for l in open(p, encoding="utf-8") if l.strip()]
labels = json.load(open("/data/labels.json"))
lab2id = {l: i for i, l in enumerate(labels)}
train_rows, eval_rows, probe_rows = load("/data/train.jsonl"), load("/data/eval.jsonl"), load("/data/probe.jsonl")
candidates = ["google/embeddinggemma-300m", "unsloth/embeddinggemma-300m", "onnx-community/embeddinggemma-300m-ONNX"]
model = None
for cid in candidates:
try:
model = SentenceTransformer(cid, device="cuda")
print("[embed model] " + cid)
break
except Exception as e:
print(f"[skip] {cid}: {str(e)[:120]}")
if model is None:
raise RuntimeError("no embeddinggemma variant loadable")
def embed(rows):
return np.asarray(model.encode([r["text"] for r in rows], batch_size=64, show_progress_bar=False, normalize_embeddings=True))
Xtr, Xev, Xpr = embed(train_rows), embed(eval_rows), embed(probe_rows)
ytr = np.array([lab2id[r["label"]] for r in train_rows])
# multinomial logistic head, full-batch
d, k = Xtr.shape[1], len(labels)
W = torch.zeros(d, k, requires_grad=True)
b = torch.zeros(k, requires_grad=True)
Xt = torch.tensor(Xtr, dtype=torch.float32)
yt = torch.tensor(ytr)
opt = torch.optim.Adam([W, b], lr=0.05)
for i in range(400):
loss = torch.nn.functional.cross_entropy(Xt @ W + b, yt) + 1e-4 * W.pow(2).sum()
opt.zero_grad(); loss.backward(); opt.step()
print(f"[head] final loss {loss.item():.4f}, dim={d}")
def acc(X, rows):
pred = (torch.tensor(X, dtype=torch.float32) @ W + b).argmax(-1).numpy()
per = {}
for r, p in zip(rows, pred):
s = per.setdefault(r["label"], [0, 0]); s[1] += 1
if labels[p] == r["label"]: s[0] += 1
overall = sum(v[0] for v in per.values()) / max(1, len(rows))
return overall, {l: f"{v[0]}/{v[1]}" for l, v in per.items()}
ev_overall, ev_per = acc(Xev, eval_rows)
pr_overall, pr_per = acc(Xpr, probe_rows)
head = {"labels": labels, "dim": d, "normalize": True,
"W": W.detach().numpy().round(5).tolist(), "b": b.detach().numpy().round(5).tolist()}
import os
os.makedirs("/out/ear-head", exist_ok=True)
with open("/out/ear-head/head.json", "w") as f:
json.dump(head, f)
vol.commit()
return {"eval_overall": round(ev_overall, 4), "eval_per_class": ev_per,
"probe_recognized": pr_per, "probe_overall": round(pr_overall, 4),
"head_kb": round(len(json.dumps(head)) / 1024)}
@app.local_entrypoint()
def main():
import json
print(json.dumps(train.remote(), indent=2))