Sasha Malahov commited on
Commit
68a82f9
·
1 Parent(s): 3868cec

example-repro: spec-faithful Qwen3-Emb+LoRA+prototype-head training on synthetic data

Browse files

Reimplements 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 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()