Sasha Malahov commited on
Commit ·
68a82f9
1
Parent(s): 3868cec
example-repro: spec-faithful Qwen3-Emb+LoRA+prototype-head training on synthetic data
Browse filesReimplements the jev-topic-head training from the public spec checklist
(no original code available): 56-anchor prototype head seeded from topic
descriptions, LoRA r16, two AdamW groups, warmup->decay, 1/sqrt(count)
sampling with hard-pair x2, early stop on val macro-F1, 3 seeds + eval.
Synthetic structural twin of the private dataset; real-data rerun pending.
- .gitignore +4 -0
- README.md +51 -0
- config.yaml +45 -0
- evaluate.py +82 -0
- make_toy_data.py +168 -0
- requirements.txt +6 -0
- results/seed0/history.json +36 -0
- results/seed0/summary.json +9 -0
- results/seed1/history.json +36 -0
- results/seed1/summary.json +9 -0
- results/seed2/history.json +36 -0
- results/seed2/summary.json +9 -0
- train_head.py +236 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
runs/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# example-repro — jev-topic-head training, reproduced from the public spec
|
| 2 |
+
|
| 3 |
+
A self-contained reimplementation of the training described in
|
| 4 |
+
`../jev-topic-head/README.md` (the scaffold), written **without the original code**:
|
| 5 |
+
Qwen3-Embedding-0.6B + LoRA(r16) + prototype head (56 anchors + learned scale),
|
| 6 |
+
on 279/85/105-item synthetic data matching the real split structure.
|
| 7 |
+
|
| 8 |
+
**This validates the pipeline, not the numbers.** The real labels live in the private
|
| 9 |
+
`Kodep/jev-topic-data` (school-email text, not pushed yet). When they land, point
|
| 10 |
+
`config.yaml: data.dir` at them and rerun; everything else stays.
|
| 11 |
+
|
| 12 |
+
## Files
|
| 13 |
+
| file | what |
|
| 14 |
+
|---|---|
|
| 15 |
+
| `config.yaml` | every hyperparameter from the spec checklist |
|
| 16 |
+
| `make_toy_data.py` | synthetic 56-topic dataset (same shapes/skew/14-unseen-topics/hard-pairs) |
|
| 17 |
+
| `train_head.py` | the training loop: seed protos from descriptions, LoRA, 2 AdamW groups, warmup→decay, 1/√count sampler, early stop on val macro-F1 |
|
| 18 |
+
| `evaluate.py` | per-item dump + top-1/macro-F1/confidence buckets (eval/ schema) |
|
| 19 |
+
|
| 20 |
+
## Run (on Sunny, 4090)
|
| 21 |
+
```bash
|
| 22 |
+
cd ~/jev-example
|
| 23 |
+
.venv/bin/python make_toy_data.py
|
| 24 |
+
.venv/bin/python train_head.py --config config.yaml --seed 0 # downloads base model once
|
| 25 |
+
.venv/bin/python evaluate.py --config config.yaml --run runs/seed0
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Spec fidelity notes
|
| 29 |
+
- Implemented from the checklist: r16/α32/dropout 0.05 on q/k/v/o + gate/up/down;
|
| 30 |
+
protos seeded from description embeddings; scale init 20; CE + label smoothing 0.05;
|
| 31 |
+
lora 3e-4 wd .01 / head 2e-3 no wd; warmup 10% → linear decay to 5%; batch 16;
|
| 32 |
+
≤12 epochs; early stop on val macro-F1 (patience 3); sampler 1/√count × hard-pair 2.
|
| 33 |
+
- Inferences the spec left open, marked in code: **last-token pooling** (official
|
| 34 |
+
Qwen3-Embedding recipe), **bf16 = bf16 base weights + fp32 head**.
|
| 35 |
+
- Show/hide rules (`rules_table.json`) are not modeled — they're a fixed downstream
|
| 36 |
+
table, not part of the head.
|
| 37 |
+
|
| 38 |
+
## Results (synthetic data, 4090, 2026-10-04)
|
| 39 |
+
- Trainable: **10,092,544 LoRA + 57,345 head params** (1.67% of 605.9M) — matches the
|
| 40 |
+
spec's shape: head = 56×1024 + 1 exactly.
|
| 41 |
+
- Early stop at epoch 4 (patience 3); best val macro-F1 0.949 (seed 0).
|
| 42 |
+
- Test vs labels, identical across seeds 0/1/2: **top-1 0.971, macro-F1 0.956**;
|
| 43 |
+
78/105 items ≥0.9 confidence; **zero errors above 0.9 confidence** — the same
|
| 44 |
+
"errors live in the low-confidence bucket" pattern the real report claims.
|
| 45 |
+
- These numbers validate plumbing only: synthetic labels are clean, the real ones are
|
| 46 |
+
Jev's noisy silver answers (real span: macro-F1 0.49–0.59).
|
| 47 |
+
|
| 48 |
+
## Status
|
| 49 |
+
Code path fully exercised end-to-end (3 seeds + evals, artifacts in `results/`).
|
| 50 |
+
Awaiting the real `Kodep/jev-topic-data` push → set `config.yaml: data.dir` to it and
|
| 51 |
+
rerun unchanged to attempt the actual 92/105 · 0.572 · 90.5% numbers.
|
config.yaml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
base_model: Qwen/Qwen3-Embedding-0.6B
|
| 2 |
+
max_length: 512
|
| 3 |
+
|
| 4 |
+
lora:
|
| 5 |
+
r: 16
|
| 6 |
+
alpha: 32
|
| 7 |
+
dropout: 0.05
|
| 8 |
+
target_modules: [q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj]
|
| 9 |
+
|
| 10 |
+
head:
|
| 11 |
+
scale_init: 20.0
|
| 12 |
+
|
| 13 |
+
loss:
|
| 14 |
+
label_smoothing: 0.05
|
| 15 |
+
|
| 16 |
+
optimizer:
|
| 17 |
+
lora_lr: 3.0e-4
|
| 18 |
+
lora_wd: 0.01
|
| 19 |
+
head_lr: 2.0e-3
|
| 20 |
+
head_wd: 0.0
|
| 21 |
+
warmup_frac: 0.10
|
| 22 |
+
final_lr_frac: 0.05
|
| 23 |
+
|
| 24 |
+
train:
|
| 25 |
+
batch_size: 16
|
| 26 |
+
epochs: 12
|
| 27 |
+
patience: 3
|
| 28 |
+
dtype: bf16 # spec; if metrics look odd on this stack, try fp32
|
| 29 |
+
|
| 30 |
+
data:
|
| 31 |
+
dir: data
|
| 32 |
+
taxonomy: topic_taxonomy.json
|
| 33 |
+
train: train.jsonl
|
| 34 |
+
validation: validation.jsonl
|
| 35 |
+
test: test.jsonl
|
| 36 |
+
# hard topic pairs get x2 sampling weight (mirrors the spec's "hard-pair topics x2")
|
| 37 |
+
hard_pairs:
|
| 38 |
+
- [soccer, sports_general]
|
| 39 |
+
- [swim_club, sports_general]
|
| 40 |
+
- [art_club, crafts_club]
|
| 41 |
+
- [music_band, choir]
|
| 42 |
+
- [flu_report, allergies]
|
| 43 |
+
- [bake_sale, fundraiser]
|
| 44 |
+
|
| 45 |
+
out_root: runs
|
evaluate.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate a trained run: top-1 vs jev_topic, macro-F1, per-item dump, confidence buckets.
|
| 2 |
+
|
| 3 |
+
Mirrors the eval/ schema promised in jev-topic-head (per_item jsonl + headline numbers).
|
| 4 |
+
No rules_table.json exists in this example, so show/hide is skipped (it's a fixed
|
| 5 |
+
lookup table downstream of the topic, not part of the head).
|
| 6 |
+
"""
|
| 7 |
+
import argparse, json, os
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
import yaml
|
| 13 |
+
from sklearn.metrics import f1_score
|
| 14 |
+
from transformers import AutoModel, AutoTokenizer
|
| 15 |
+
from peft import PeftModel
|
| 16 |
+
|
| 17 |
+
from train_head import PrototypeHead, encode, read_jsonl
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def main():
|
| 21 |
+
ap = argparse.ArgumentParser()
|
| 22 |
+
ap.add_argument("--config", default="config.yaml")
|
| 23 |
+
ap.add_argument("--run", required=True, help="e.g. runs/seed0")
|
| 24 |
+
args = ap.parse_args()
|
| 25 |
+
cfg = yaml.safe_load(open(args.config))
|
| 26 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 27 |
+
dtype = torch.bfloat16 if cfg["train"]["dtype"] == "bf16" else torch.float32
|
| 28 |
+
|
| 29 |
+
hs = torch.load(os.path.join(args.run, "head", "head.pt"), map_location="cpu")
|
| 30 |
+
topics = hs["topics"]
|
| 31 |
+
topic2id = {t: i for i, t in enumerate(topics)}
|
| 32 |
+
|
| 33 |
+
tok = AutoTokenizer.from_pretrained(cfg["base_model"])
|
| 34 |
+
tok.padding_side = "right"
|
| 35 |
+
base = AutoModel.from_pretrained(hs.get("base_model", cfg["base_model"]),
|
| 36 |
+
torch_dtype=dtype).to(device)
|
| 37 |
+
base.config.use_cache = False
|
| 38 |
+
model = PeftModel.from_pretrained(base, os.path.join(args.run, "lora"))
|
| 39 |
+
|
| 40 |
+
head = PrototypeHead(hs["protos"], float(hs["scale"]))
|
| 41 |
+
head.load_state_dict({"protos": hs["protos"], "scale": hs["scale"]})
|
| 42 |
+
head = head.to(device).eval()
|
| 43 |
+
|
| 44 |
+
rows = read_jsonl(os.path.join(cfg["data"]["dir"], cfg["data"]["test"]))
|
| 45 |
+
texts = [r["text"] for r in rows]
|
| 46 |
+
logits, BATCH = [], 32
|
| 47 |
+
with torch.no_grad():
|
| 48 |
+
for i in range(0, len(texts), BATCH):
|
| 49 |
+
v = encode(model, tok, texts[i:i + BATCH], cfg["max_length"], device)
|
| 50 |
+
logits.append(head.logits(v).float().cpu())
|
| 51 |
+
logits = torch.cat(logits)
|
| 52 |
+
probs = F.softmax(logits, dim=-1)
|
| 53 |
+
conf, pred = probs.max(-1)
|
| 54 |
+
pred = pred.numpy()
|
| 55 |
+
gold = np.array([topic2id[r["jev_topic"]] for r in rows])
|
| 56 |
+
|
| 57 |
+
per_item = []
|
| 58 |
+
for r, p, c in zip(rows, pred, conf.numpy()):
|
| 59 |
+
per_item.append({"id": r["id"], "model_topic": topics[p],
|
| 60 |
+
"jev_topic": r["jev_topic"], "confidence": round(float(c), 4),
|
| 61 |
+
"agree": bool(p == topic2id[r["jev_topic"]])})
|
| 62 |
+
os.makedirs(os.path.join(args.run, "eval"), exist_ok=True)
|
| 63 |
+
with open(os.path.join(args.run, "eval", "per_item_test.jsonl"), "w") as f:
|
| 64 |
+
for r in per_item:
|
| 65 |
+
f.write(json.dumps(r) + "\n")
|
| 66 |
+
|
| 67 |
+
top1 = float((pred == gold).mean())
|
| 68 |
+
mf1 = float(f1_score(gold, pred, average="macro", zero_division=0,
|
| 69 |
+
labels=list(range(len(topics)))))
|
| 70 |
+
conf_all = conf.numpy()
|
| 71 |
+
summary = {"n": len(rows), "top1_vs_jev": round(top1, 4),
|
| 72 |
+
"macro_f1_vs_jev": round(mf1, 4),
|
| 73 |
+
"conf_lt_05": int((conf_all < 0.5).sum()),
|
| 74 |
+
"conf_gte_09": int((conf_all >= 0.9).sum()),
|
| 75 |
+
"errors_lt_05": int(((pred != gold) & (conf_all < 0.5)).sum()),
|
| 76 |
+
"errors_gte_09": int(((pred != gold) & (conf_all >= 0.9)).sum())}
|
| 77 |
+
json.dump(summary, open(os.path.join(args.run, "eval", "summary.json"), "w"), indent=2)
|
| 78 |
+
print(json.dumps(summary, indent=2))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
main()
|
make_toy_data.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic stand-in for Kodep/jev-topic-data (which is private and not yet pushed).
|
| 2 |
+
|
| 3 |
+
Same structure as the spec: 56 topics with descriptions, 279 train / 85 val / 105 test
|
| 4 |
+
items, skewed class counts, 14 topics with no training items, hard pairs sharing
|
| 5 |
+
vocabulary. The TOPICS are ours and the text is machine-generated — treat the metrics
|
| 6 |
+
as pipeline validation, not as a benchmark.
|
| 7 |
+
"""
|
| 8 |
+
import json, os, random
|
| 9 |
+
|
| 10 |
+
TOPICS = [
|
| 11 |
+
# (name, description, keywords) — pairs listed in config hard_pairs share keywords
|
| 12 |
+
("soccer", "soccer practice, games and team news", ["soccer", "practice", "field", "game"]),
|
| 13 |
+
("sports_general", "general sports and athletic program news", ["sports", "practice", "game", "athletic"]),
|
| 14 |
+
("swim_club", "swim club sessions, meets and pool rules", ["swim", "pool", "meet", "lap"]),
|
| 15 |
+
("basketball", "basketball schedule and gym bookings", ["basketball", "gym", "court", "tournament"]),
|
| 16 |
+
("athletics_office", "athletics office announcements", ["athletic", "office", "booster"]),
|
| 17 |
+
("field_trip", "field trip itineraries, buses and permission", ["field trip", "bus", "itinerary", "chaperone"]),
|
| 18 |
+
("permission_slip", "permission slips and signature reminders", ["permission", "slip", "signature", "consent"]),
|
| 19 |
+
("lunch_menu", "weekly lunch menu and meal options", ["lunch", "menu", "meal", "cafeteria"]),
|
| 20 |
+
("cafeteria_rules", "cafeteria expectations and seating", ["cafeteria", "seating", "rules", "lunch"]),
|
| 21 |
+
("bake_sale", "bake sales and baked goods fundraisers", ["bake sale", "cookies", "sale", "fundraiser"]),
|
| 22 |
+
("fundraiser", "fundraisers, drives and donation goals", ["fundraiser", "donation", "goal", "sale"]),
|
| 23 |
+
("art_club", "art club projects, medium and shows", ["art", "paint", "project", "exhibit"]),
|
| 24 |
+
("crafts_club", "crafts club projects and supplies", ["crafts", "glue", "project", "supplies"]),
|
| 25 |
+
("drama_club", "drama club rehearsals and tickets", ["drama", "rehearsal", "play", "tickets"]),
|
| 26 |
+
("music_band", "concert band rehearsals and concerts", ["band", "concert", "performance", "instrument"]),
|
| 27 |
+
("choir", "choir practices and winter concert", ["choir", "concert", "performance", "sing"]),
|
| 28 |
+
("orchestra", "orchestra sectionals and repertoire", ["orchestra", "sectional", "violin", "concert"]),
|
| 29 |
+
("reading_week", "reading week challenges and logs", ["reading", "challenge", "log", "books"]),
|
| 30 |
+
("library", "library hours, returns and fines", ["library", "return", "books", "due"]),
|
| 31 |
+
("spelling_bee", "spelling bee rounds and registration", ["spelling", "bee", "round", "word"]),
|
| 32 |
+
("math_club", "math club contests and puzzles", ["math", "contest", "puzzle", "problem"]),
|
| 33 |
+
("math_olympiad", "math olympiad training and teams", ["math", "olympiad", "team", "contest"]),
|
| 34 |
+
("science_fair", "science fair projects and judging", ["science", "fair", "project", "judging"]),
|
| 35 |
+
("robotics", "robotics builds and competition", ["robotics", "robot", "build", "competition"]),
|
| 36 |
+
("coding_club", "coding club sessions and projects", ["coding", "app", "scratch", "project"]),
|
| 37 |
+
("history_day", "history day topics and papers", ["history", "paper", "topic", "exhibit"]),
|
| 38 |
+
("debate_club", "debate rounds and motions", ["debate", "round", "motion", "argument"]),
|
| 39 |
+
("chess_club", "chess club ladder and matches", ["chess", "match", "ladder", "tournament"]),
|
| 40 |
+
("homework", "homework policies and support", ["homework", "assignment", "due", "tutoring"]),
|
| 41 |
+
("grades", "grade reporting and conferences", ["grades", "report card", "conference", "gpa"]),
|
| 42 |
+
("attendance", "attendance windows and absences", ["attendance", "absence", "tardy", "excused"]),
|
| 43 |
+
("late_pickup", "late pickup procedures and fees", ["pickup", "late", "fee", "after school"]),
|
| 44 |
+
("early_dismissal", "early dismissal and snow schedules", ["dismissal", "early", "schedule", "snow"]),
|
| 45 |
+
("weather_closing", "weather closings and delays", ["closing", "delay", "weather", "snow"]),
|
| 46 |
+
("bus_route", "bus route changes and stops", ["bus", "route", "stop", "ride"]),
|
| 47 |
+
("carpool", "carpool coordination boards", ["carpool", "drivers", "roster", "pickup"]),
|
| 48 |
+
("flu_report", "illness, flu and nurse notes", ["flu", "nurse", "illness", "outbreak"]),
|
| 49 |
+
("allergies", "allergy alerts and food policies", ["allergy", "food", "nurse", "epipen"]),
|
| 50 |
+
("photo_day", "school photos and portrait packages", ["photo", "portrait", "package", "picture"]),
|
| 51 |
+
("dentist_note", "dental screening and vision checks", ["dental", "vision", "screening", "note"]),
|
| 52 |
+
("volunteer", "volunteer hours and signups", ["volunteer", "hours", "signup", "helper"]),
|
| 53 |
+
("pta_meeting", "PTA meetings and officers", ["pta", "meeting", "officer", "dues"]),
|
| 54 |
+
("textbook", "textbook and supply lists", ["textbook", "supply", "list", "checkout"]),
|
| 55 |
+
("locker", "locker assignments and rules", ["locker", "combination", "assignment", "hall"]),
|
| 56 |
+
("dress_code", "dress code and spirit days", ["dress code", "spirit", "attire", "jeans"]),
|
| 57 |
+
("concert_tickets", "event ticket sales and seating", ["tickets", "seating", "presale", "entry"]),
|
| 58 |
+
("yearbook", "yearbook ads, photos and delivery", ["yearbook", "ad", "delivery", "super senior"]),
|
| 59 |
+
("litter_pickup", "litter and grounds volunteer days", ["litter", "grounds", "cleanup", "volunteer"]),
|
| 60 |
+
("garden", "school garden planting and care", ["garden", "plant", "soil", "harvest"]),
|
| 61 |
+
("astronomy_night", "stargazing and planetarium trips", ["astronomy", "stars", "planetarium", "telescope"]),
|
| 62 |
+
("museums_late", "museum nights and docent tours", ["museum", "docent", "tour", "gallery"]),
|
| 63 |
+
("wellness", "wellness checks and health screenings", ["wellness", "health", "screening", "check"]),
|
| 64 |
+
("tech_help", "device help and wifi support", ["device", "wifi", "chromebook", "support"]),
|
| 65 |
+
("hall_pass", "hall pass and study hall policies", ["hall pass", "study hall", "pass", "policy"]),
|
| 66 |
+
("lunch_duty", "duty rosters and coverage", ["duty", "lunch duty", "coverage", "roster"]),
|
| 67 |
+
("staff_appreciation", "staff thank-you and Appreciation Week", ["staff", "appreciation", "thank you", "week"]),
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
HARD = {"soccer", "sports_general", "swim_club", "art_club", "crafts_club", "music_band",
|
| 71 |
+
"choir", "flu_report", "allergies", "bake_sale", "fundraiser"}
|
| 72 |
+
|
| 73 |
+
FILLERS = [
|
| 74 |
+
"Please reply-all if anything changed since last week.",
|
| 75 |
+
"Mrs. Delgado left this note for the whole distribution list.",
|
| 76 |
+
"Reminder forwarded from the office with no further edits.",
|
| 77 |
+
"The link in the original message was left out of this copy.",
|
| 78 |
+
"This item repeats in three separate newsletters this month.",
|
| 79 |
+
"A follow-up message may or may not arrive later.",
|
| 80 |
+
"Someone on the list has asked to be removed after the fact.",
|
| 81 |
+
"The date appears twice and the two dates disagree.",
|
| 82 |
+
]
|
| 83 |
+
EVENTS = ["reminder", "update", "signup now", "schedule change", "final call", "quick note"]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def make_text(rng, topic):
|
| 87 |
+
kws = TOPIC_KW[topic]
|
| 88 |
+
bits = [rng.choice(kws), rng.choice(kws)]
|
| 89 |
+
rng.shuffle(bits)
|
| 90 |
+
text = f"{topic.replace('_', ' ').title()}: {rng.choice(EVENTS)} - {bits[0]} and {bits[1]} this week."
|
| 91 |
+
if rng.random() < 0.35:
|
| 92 |
+
text += f" Also mention {rng.choice(kws)} briefly near the end."
|
| 93 |
+
if rng.random() < 0.30:
|
| 94 |
+
text += " " + rng.choice(FILLERS)
|
| 95 |
+
if rng.random() < 0.12:
|
| 96 |
+
text += " " + " ".join([rng.choice(FILLERS), rng.choice(FILLERS)])
|
| 97 |
+
return text
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def main(out_dir):
|
| 101 |
+
rng = random.Random(42)
|
| 102 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 103 |
+
global TOPIC_KW
|
| 104 |
+
TOPIC_KW = {t[0]: t[2] for t in TOPICS}
|
| 105 |
+
names = [t[0] for t in TOPICS]
|
| 106 |
+
assert len(names) == 56, len(names)
|
| 107 |
+
|
| 108 |
+
taxonomy = {name: desc for name, desc, _ in TOPICS}
|
| 109 |
+
with open(os.path.join(out_dir, "topic_taxonomy.json"), "w") as f:
|
| 110 |
+
json.dump(taxonomy, f, indent=2)
|
| 111 |
+
|
| 112 |
+
# skewed train counts: a few big topics, a long tail, 14 topics with no train items
|
| 113 |
+
train_pool = [n for n in names if n not in rng.sample(names, 14)]
|
| 114 |
+
counts = {}
|
| 115 |
+
remaining = 279
|
| 116 |
+
for i, n in enumerate(train_pool):
|
| 117 |
+
w = rng.choice([1, 1, 2, 2, 3, 5]) if n in HARD else rng.choice([1, 1, 1, 2, 2, 4, 7])
|
| 118 |
+
counts[n] = max(1, w)
|
| 119 |
+
scale = remaining / sum(counts.values())
|
| 120 |
+
counts = {n: max(1, int(c * scale + 0.5)) for n, c in counts.items()}
|
| 121 |
+
# land exactly on 279, keeping every kept topic >= 1 item
|
| 122 |
+
order = [n for n in train_pool if counts[n] > 1]
|
| 123 |
+
i = 0
|
| 124 |
+
while sum(counts.values()) != 279:
|
| 125 |
+
n = order[i % len(order)]
|
| 126 |
+
if sum(counts.values()) < 279:
|
| 127 |
+
counts[n] += 1
|
| 128 |
+
else:
|
| 129 |
+
counts[n] -= 1
|
| 130 |
+
i += 1
|
| 131 |
+
|
| 132 |
+
idx = 0
|
| 133 |
+
def emit(split, topic):
|
| 134 |
+
nonlocal idx
|
| 135 |
+
idx += 1
|
| 136 |
+
return {"id": f"toy-{idx:04d}", "email_id": f"email-{idx:03d}",
|
| 137 |
+
"text": make_text(rng, topic), "jev_topic": topic, "human_topic": None}
|
| 138 |
+
|
| 139 |
+
train = [emit("train", n) for n, c in counts.items() for _ in range(c)]
|
| 140 |
+
assert len(train) == 279, len(train)
|
| 141 |
+
rng.shuffle(train)
|
| 142 |
+
|
| 143 |
+
# val + test: cover all 56 topics; unseen topics appear only here
|
| 144 |
+
val, test = [], []
|
| 145 |
+
for n in names:
|
| 146 |
+
for _ in range(1):
|
| 147 |
+
val.append(emit("validation", n))
|
| 148 |
+
while len(val) < 85:
|
| 149 |
+
val.append(emit("validation", rng.choice(names)))
|
| 150 |
+
for n in names:
|
| 151 |
+
test.append(emit("test", n))
|
| 152 |
+
while len(test) < 105:
|
| 153 |
+
test.append(emit("test", rng.choice(names)))
|
| 154 |
+
|
| 155 |
+
for split, rows in [("train", train), ("validation", val), ("test", test)]:
|
| 156 |
+
with open(os.path.join(out_dir, f"{split}.jsonl"), "w") as f:
|
| 157 |
+
for r in rows:
|
| 158 |
+
f.write(json.dumps(r) + "\n")
|
| 159 |
+
print(split, len(rows))
|
| 160 |
+
big = sorted(counts.items(), key=lambda kv: -kv[1])[:5]
|
| 161 |
+
print("busiest topics:", big, "| train topics:", len(counts), "/56")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == "__main__":
|
| 165 |
+
import argparse
|
| 166 |
+
ap = argparse.ArgumentParser()
|
| 167 |
+
ap.add_argument("--out", default="data")
|
| 168 |
+
main(ap.parse_args().out)
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# versions as pinned on the box (2026-10-04, inside ~/jev-example/.venv with
|
| 2 |
+
# --system-site-packages on conda base: torch 2.10.0+cu126 comes from base)
|
| 3 |
+
numpy==1.26.4
|
| 4 |
+
peft==0.21.2
|
| 5 |
+
# from conda base (do NOT reinstall into the venv):
|
| 6 |
+
# torch 2.10.0+cu126, transformers 5.2.0, scikit-learn 1.3.2, scipy 1.11.3, huggingface-hub
|
results/seed0/history.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seed": 0,
|
| 3 |
+
"best_val_macro_f1": 0.949404761904762,
|
| 4 |
+
"history": [
|
| 5 |
+
{
|
| 6 |
+
"epoch": 0,
|
| 7 |
+
"loss": 0.5672127770053016,
|
| 8 |
+
"val_acc": 0.9529411764705882,
|
| 9 |
+
"val_macro_f1": 0.9289965986394558
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"epoch": 1,
|
| 13 |
+
"loss": 0.44206444422403973,
|
| 14 |
+
"val_acc": 0.9647058823529412,
|
| 15 |
+
"val_macro_f1": 0.949404761904762
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"epoch": 2,
|
| 19 |
+
"loss": 0.41050556467639077,
|
| 20 |
+
"val_acc": 0.9647058823529412,
|
| 21 |
+
"val_macro_f1": 0.949404761904762
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"epoch": 3,
|
| 25 |
+
"loss": 0.4040314075019624,
|
| 26 |
+
"val_acc": 0.9647058823529412,
|
| 27 |
+
"val_macro_f1": 0.949404761904762
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"epoch": 4,
|
| 31 |
+
"loss": 0.40162890156110126,
|
| 32 |
+
"val_acc": 0.9647058823529412,
|
| 33 |
+
"val_macro_f1": 0.949404761904762
|
| 34 |
+
}
|
| 35 |
+
]
|
| 36 |
+
}
|
results/seed0/summary.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n": 105,
|
| 3 |
+
"top1_vs_jev": 0.9714,
|
| 4 |
+
"macro_f1_vs_jev": 0.9562,
|
| 5 |
+
"conf_lt_05": 3,
|
| 6 |
+
"conf_gte_09": 78,
|
| 7 |
+
"errors_lt_05": 1,
|
| 8 |
+
"errors_gte_09": 0
|
| 9 |
+
}
|
results/seed1/history.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seed": 1,
|
| 3 |
+
"best_val_macro_f1": 0.949404761904762,
|
| 4 |
+
"history": [
|
| 5 |
+
{
|
| 6 |
+
"epoch": 0,
|
| 7 |
+
"loss": 0.589680939912796,
|
| 8 |
+
"val_acc": 0.9529411764705882,
|
| 9 |
+
"val_macro_f1": 0.9289965986394558
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"epoch": 1,
|
| 13 |
+
"loss": 0.43270348343584275,
|
| 14 |
+
"val_acc": 0.9647058823529412,
|
| 15 |
+
"val_macro_f1": 0.949404761904762
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"epoch": 2,
|
| 19 |
+
"loss": 0.40894367297490436,
|
| 20 |
+
"val_acc": 0.9647058823529412,
|
| 21 |
+
"val_macro_f1": 0.949404761904762
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"epoch": 3,
|
| 25 |
+
"loss": 0.4042018949985504,
|
| 26 |
+
"val_acc": 0.9647058823529412,
|
| 27 |
+
"val_macro_f1": 0.949404761904762
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"epoch": 4,
|
| 31 |
+
"loss": 0.40101376672585803,
|
| 32 |
+
"val_acc": 0.9647058823529412,
|
| 33 |
+
"val_macro_f1": 0.9488095238095239
|
| 34 |
+
}
|
| 35 |
+
]
|
| 36 |
+
}
|
results/seed1/summary.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n": 105,
|
| 3 |
+
"top1_vs_jev": 0.9714,
|
| 4 |
+
"macro_f1_vs_jev": 0.9562,
|
| 5 |
+
"conf_lt_05": 2,
|
| 6 |
+
"conf_gte_09": 78,
|
| 7 |
+
"errors_lt_05": 1,
|
| 8 |
+
"errors_gte_09": 0
|
| 9 |
+
}
|
results/seed2/history.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seed": 2,
|
| 3 |
+
"best_val_macro_f1": 0.949404761904762,
|
| 4 |
+
"history": [
|
| 5 |
+
{
|
| 6 |
+
"epoch": 0,
|
| 7 |
+
"loss": 0.5810238288508521,
|
| 8 |
+
"val_acc": 0.9529411764705882,
|
| 9 |
+
"val_macro_f1": 0.9289965986394558
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"epoch": 1,
|
| 13 |
+
"loss": 0.4381559623612298,
|
| 14 |
+
"val_acc": 0.9647058823529412,
|
| 15 |
+
"val_macro_f1": 0.949404761904762
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"epoch": 2,
|
| 19 |
+
"loss": 0.4109099937809838,
|
| 20 |
+
"val_acc": 0.9647058823529412,
|
| 21 |
+
"val_macro_f1": 0.949404761904762
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"epoch": 3,
|
| 25 |
+
"loss": 0.4046083192030589,
|
| 26 |
+
"val_acc": 0.9647058823529412,
|
| 27 |
+
"val_macro_f1": 0.949404761904762
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"epoch": 4,
|
| 31 |
+
"loss": 0.4006368964910507,
|
| 32 |
+
"val_acc": 0.9647058823529412,
|
| 33 |
+
"val_macro_f1": 0.949404761904762
|
| 34 |
+
}
|
| 35 |
+
]
|
| 36 |
+
}
|
results/seed2/summary.json
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"n": 105,
|
| 3 |
+
"top1_vs_jev": 0.9714,
|
| 4 |
+
"macro_f1_vs_jev": 0.9562,
|
| 5 |
+
"conf_lt_05": 3,
|
| 6 |
+
"conf_gte_09": 71,
|
| 7 |
+
"errors_lt_05": 1,
|
| 8 |
+
"errors_gte_09": 0
|
| 9 |
+
}
|
train_head.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qwen3-Embedding-0.6B + LoRA + prototype head — faithful example of the jev-topic-head spec.
|
| 2 |
+
|
| 3 |
+
Mirrors: LoRA r16/a32/dropout .05 on attention+FFN (rest frozen); head = 56 anchors seeded
|
| 4 |
+
from topic-description embeddings + one learned scale (init 20); logits = scale * cos(v, P);
|
| 5 |
+
CE w/ label smoothing .05; two AdamW groups (lora 3e-4 wd .01 / head 2e-3 no wd); warmup
|
| 6 |
+
10% -> linear decay to 5%; WeightedRandomSampler 1/sqrt(count), hard pairs x2; early stop on
|
| 7 |
+
val macro-F1; seeds; ship nothing, just save runs/seed{S}/.
|
| 8 |
+
|
| 9 |
+
Deviations from the (partly inferred) spec, on purpose and documented:
|
| 10 |
+
- last-token pooling per the official Qwen3-Embedding recipe
|
| 11 |
+
- "bf16" read as bf16 base weights, fp32 head; fp32 end to end if dtype: fp32
|
| 12 |
+
- data is synthetic (make_toy_data.py), so scores validate plumbing, not quality
|
| 13 |
+
"""
|
| 14 |
+
import argparse, copy, json, math, os, random
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn as nn
|
| 19 |
+
import torch.nn.functional as F
|
| 20 |
+
import yaml
|
| 21 |
+
from sklearn.metrics import f1_score
|
| 22 |
+
from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler
|
| 23 |
+
from transformers import AutoModel, AutoTokenizer
|
| 24 |
+
from peft import LoraConfig, get_peft_model
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def set_seed(s):
|
| 28 |
+
random.seed(s)
|
| 29 |
+
np.random.seed(s)
|
| 30 |
+
torch.manual_seed(s)
|
| 31 |
+
torch.cuda.manual_seed_all(s)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def read_jsonl(path):
|
| 35 |
+
rows = []
|
| 36 |
+
with open(path) as f:
|
| 37 |
+
for line in f:
|
| 38 |
+
rows.append(json.loads(line))
|
| 39 |
+
return rows
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ItemDataset(Dataset):
|
| 43 |
+
def __init__(self, path, topic2id):
|
| 44 |
+
self.rows = read_jsonl(path)
|
| 45 |
+
self.t2i = topic2id
|
| 46 |
+
|
| 47 |
+
def __len__(self):
|
| 48 |
+
return len(self.rows)
|
| 49 |
+
|
| 50 |
+
def __getitem__(self, i):
|
| 51 |
+
r = self.rows[i]
|
| 52 |
+
return r["text"], self.t2i[r["jev_topic"]]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def collate(batch):
|
| 56 |
+
texts, ys = zip(*batch)
|
| 57 |
+
return list(texts), torch.tensor(ys, dtype=torch.long)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def encode(model, tok, texts, max_length, device):
|
| 61 |
+
"""Last-token pooling (Qwen3-Embedding recipe), fp32 output."""
|
| 62 |
+
enc = tok(texts, padding=True, truncation=True, max_length=max_length,
|
| 63 |
+
return_tensors="pt").to(device)
|
| 64 |
+
out = model(**enc).last_hidden_state
|
| 65 |
+
mask = enc["attention_mask"]
|
| 66 |
+
last = mask.sum(dim=1) - 1
|
| 67 |
+
v = out[torch.arange(out.size(0), device=device), last]
|
| 68 |
+
return v.float()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class PrototypeHead(nn.Module):
|
| 72 |
+
def __init__(self, seed_protos, scale_init):
|
| 73 |
+
super().__init__()
|
| 74 |
+
self.protos = nn.Parameter(seed_protos.clone().float())
|
| 75 |
+
self.scale = nn.Parameter(torch.tensor(float(scale_init)))
|
| 76 |
+
|
| 77 |
+
def logits(self, v):
|
| 78 |
+
v = F.normalize(v, dim=-1)
|
| 79 |
+
p = F.normalize(self.protos, dim=-1)
|
| 80 |
+
return self.scale * (v @ p.t())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@torch.no_grad()
|
| 84 |
+
def predict_logits(model, head, tok, texts, max_length, device, batch=32):
|
| 85 |
+
model.eval()
|
| 86 |
+
all_logits = []
|
| 87 |
+
for i in range(0, len(texts), batch):
|
| 88 |
+
v = encode(model, tok, texts[i:i + batch], max_length, device)
|
| 89 |
+
all_logits.append(head.logits(v).float().cpu())
|
| 90 |
+
return torch.cat(all_logits)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def macro_f1(y_true, y_pred, n_topics):
|
| 94 |
+
return f1_score(y_true, y_pred, average="macro", zero_division=0,
|
| 95 |
+
labels=list(range(n_topics)))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def main():
|
| 99 |
+
ap = argparse.ArgumentParser()
|
| 100 |
+
ap.add_argument("--config", default="config.yaml")
|
| 101 |
+
ap.add_argument("--seed", type=int, default=None)
|
| 102 |
+
args = ap.parse_args()
|
| 103 |
+
cfg = yaml.safe_load(open(args.config))
|
| 104 |
+
seed = args.seed if args.seed is not None else 0
|
| 105 |
+
set_seed(seed)
|
| 106 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 107 |
+
dtype = torch.bfloat16 if cfg["train"]["dtype"] == "bf16" else torch.float32
|
| 108 |
+
|
| 109 |
+
data_dir = cfg["data"]["dir"]
|
| 110 |
+
taxonomy = json.load(open(os.path.join(data_dir, cfg["data"]["taxonomy"])))
|
| 111 |
+
topics = sorted(taxonomy) # fixed, seed-independent order
|
| 112 |
+
topic2id = {t: i for i, t in enumerate(topics)}
|
| 113 |
+
K = len(topics)
|
| 114 |
+
|
| 115 |
+
tok = AutoTokenizer.from_pretrained(cfg["base_model"])
|
| 116 |
+
tok.padding_side = "right"
|
| 117 |
+
base = AutoModel.from_pretrained(cfg["base_model"], torch_dtype=dtype).to(device)
|
| 118 |
+
base.config.use_cache = False
|
| 119 |
+
|
| 120 |
+
# ---- seed prototypes from topic-description embeddings (one forward pass) ----
|
| 121 |
+
descs = [taxonomy[t] for t in topics]
|
| 122 |
+
seed_protos = encode(base, tok, descs, cfg["max_length"], device)
|
| 123 |
+
print(f"seeded protos {tuple(seed_protos.shape)}")
|
| 124 |
+
|
| 125 |
+
# ---- LoRA: the only thing that changes the encoder ----
|
| 126 |
+
lcfg = LoraConfig(r=cfg["lora"]["r"], lora_alpha=cfg["lora"]["alpha"],
|
| 127 |
+
lora_dropout=cfg["lora"]["dropout"],
|
| 128 |
+
target_modules=cfg["lora"]["target_modules"])
|
| 129 |
+
model = get_peft_model(base, lcfg)
|
| 130 |
+
model.print_trainable_parameters()
|
| 131 |
+
|
| 132 |
+
head = PrototypeHead(seed_protos, cfg["head"]["scale_init"]).to(device)
|
| 133 |
+
|
| 134 |
+
lora_params = [p for p in model.parameters() if p.requires_grad]
|
| 135 |
+
n_lora = sum(p.numel() for p in lora_params)
|
| 136 |
+
n_head = sum(p.numel() for p in head.parameters())
|
| 137 |
+
print(f"trainable: {n_lora:,} lora + {n_head:,} head, rest frozen")
|
| 138 |
+
|
| 139 |
+
# ---- data + class-balanced sampling (1/sqrt(count), hard pairs x2) ----
|
| 140 |
+
train_rows = read_jsonl(os.path.join(data_dir, cfg["data"]["train"]))
|
| 141 |
+
counts = {}
|
| 142 |
+
for r in train_rows:
|
| 143 |
+
counts[r["jev_topic"]] = counts.get(r["jev_topic"], 0) + 1
|
| 144 |
+
hard = set()
|
| 145 |
+
for a, b in cfg["data"].get("hard_pairs", []):
|
| 146 |
+
hard.update([a, b])
|
| 147 |
+
weights = [1.0 / math.sqrt(counts.get(r["jev_topic"], 1)) * (2.0 if r["jev_topic"] in hard else 1.0)
|
| 148 |
+
for r in train_rows]
|
| 149 |
+
sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)
|
| 150 |
+
|
| 151 |
+
ds = ItemDataset(os.path.join(data_dir, cfg["data"]["train"]), topic2id)
|
| 152 |
+
dl = DataLoader(ds, batch_size=cfg["train"]["batch_size"], sampler=sampler,
|
| 153 |
+
collate_fn=collate)
|
| 154 |
+
val_ds = ItemDataset(os.path.join(data_dir, cfg["data"]["validation"]), topic2id)
|
| 155 |
+
val_texts = [r["text"] for r in val_ds.rows]
|
| 156 |
+
val_y = [topic2id[r["jev_topic"]] for r in val_ds.rows]
|
| 157 |
+
|
| 158 |
+
# ---- two AdamW groups + warmup->linear-decay-to-5% schedule ----
|
| 159 |
+
o = cfg["optimizer"]
|
| 160 |
+
opt = torch.optim.AdamW([
|
| 161 |
+
{"params": lora_params, "lr": o["lora_lr"], "weight_decay": o["lora_wd"]},
|
| 162 |
+
{"params": list(head.parameters()), "lr": o["head_lr"], "weight_decay": o["head_wd"]},
|
| 163 |
+
])
|
| 164 |
+
epochs = cfg["train"]["epochs"]
|
| 165 |
+
spe = math.ceil(len(ds) / cfg["train"]["batch_size"])
|
| 166 |
+
total_steps = epochs * spe
|
| 167 |
+
warmup = max(1, int(o["warmup_frac"] * total_steps))
|
| 168 |
+
floor = o["final_lr_frac"]
|
| 169 |
+
|
| 170 |
+
def lr_lambda(step):
|
| 171 |
+
if step < warmup:
|
| 172 |
+
return (step + 1) / warmup
|
| 173 |
+
prog = (step - warmup) / max(1, total_steps - warmup)
|
| 174 |
+
return floor + (1.0 - floor) * (1.0 - min(1.0, prog))
|
| 175 |
+
|
| 176 |
+
sched = torch.optim.lr_scheduler.LambdaLR(opt, lr_lambda)
|
| 177 |
+
|
| 178 |
+
out_dir = os.path.join(cfg["out_root"], f"seed{seed}")
|
| 179 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 180 |
+
history, best_f1, best_state = [], -1.0, None
|
| 181 |
+
patience, bad = cfg["train"]["patience"], 0
|
| 182 |
+
|
| 183 |
+
for ep in range(epochs):
|
| 184 |
+
model.train()
|
| 185 |
+
head.train()
|
| 186 |
+
losses = []
|
| 187 |
+
for texts, y in dl:
|
| 188 |
+
y = y.to(device)
|
| 189 |
+
v = encode(model, tok, texts, cfg["max_length"], device)
|
| 190 |
+
logits = head.logits(v)
|
| 191 |
+
loss = F.cross_entropy(logits, y,
|
| 192 |
+
label_smoothing=cfg["loss"]["label_smoothing"])
|
| 193 |
+
opt.zero_grad(set_to_none=True)
|
| 194 |
+
loss.backward()
|
| 195 |
+
opt.step()
|
| 196 |
+
sched.step()
|
| 197 |
+
losses.append(loss.item())
|
| 198 |
+
|
| 199 |
+
lg = predict_logits(model, head, tok, val_texts, cfg["max_length"], device)
|
| 200 |
+
pred = lg.argmax(-1).numpy()
|
| 201 |
+
f1 = macro_f1(val_y, pred, K)
|
| 202 |
+
acc = float((pred == np.array(val_y)).mean())
|
| 203 |
+
print(f"epoch {ep:2d} | loss {np.mean(losses):.3f} | val acc {acc:.3f} | val macro-F1 {f1:.4f}")
|
| 204 |
+
history.append({"epoch": ep, "loss": float(np.mean(losses)),
|
| 205 |
+
"val_acc": acc, "val_macro_f1": float(f1)})
|
| 206 |
+
|
| 207 |
+
if f1 > best_f1:
|
| 208 |
+
best_f1, bad = float(f1), 0
|
| 209 |
+
best_state = {"head": copy.deepcopy(head.state_dict()),
|
| 210 |
+
"lora": {k: v.detach().cpu().clone()
|
| 211 |
+
for k, v in model.state_dict().items()
|
| 212 |
+
if "lora_" in k and "requires_grad" not in k}}
|
| 213 |
+
else:
|
| 214 |
+
bad += 1
|
| 215 |
+
if bad >= patience:
|
| 216 |
+
print(f"early stop at epoch {ep} (patience {patience})")
|
| 217 |
+
break
|
| 218 |
+
|
| 219 |
+
# ---- save best (the shipped artifact is seed 0, like the spec) ----
|
| 220 |
+
model.load_state_dict(best_state["lora"], strict=False)
|
| 221 |
+
model.save_pretrained(os.path.join(out_dir, "lora"))
|
| 222 |
+
head.load_state_dict(best_state["head"])
|
| 223 |
+
head_dir = os.path.join(out_dir, "head")
|
| 224 |
+
os.makedirs(head_dir, exist_ok=True)
|
| 225 |
+
torch.save({"protos": head.protos.detach().cpu(),
|
| 226 |
+
"scale": head.scale.detach().cpu(),
|
| 227 |
+
"dim": head.protos.shape[1], "topics": topics,
|
| 228 |
+
"base_model": cfg["base_model"]},
|
| 229 |
+
os.path.join(head_dir, "head.pt"))
|
| 230 |
+
json.dump({"seed": seed, "best_val_macro_f1": best_f1, "history": history},
|
| 231 |
+
open(os.path.join(out_dir, "history.json"), "w"), indent=2)
|
| 232 |
+
print(f"saved {out_dir} | best val macro-F1 {best_f1:.4f}")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
main()
|