peter2000 commited on
Commit
c9604b1
·
verified ·
1 Parent(s): 293c8a8

Add training script: RLCD fine-tune of laya on GIZ vulnerability data (17 binary questions per text)

Browse files
Files changed (1) hide show
  1. train_laya.py +425 -0
train_laya.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ os.environ.setdefault("USE_TF", "0")
3
+ os.environ.setdefault("USE_TORCH", "1")
4
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
5
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
6
+
7
+ import json
8
+ import random
9
+ import shutil
10
+ import time
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import torch
15
+ import trackio
16
+
17
+ def _trackio_safe(fn):
18
+ def wrapper(*a, **k):
19
+ try:
20
+ return fn(*a, **k)
21
+ except Exception as e:
22
+ print(f"trackio.{fn.__name__} failed (non-fatal): {e}")
23
+ return wrapper
24
+ trackio.log = _trackio_safe(trackio.log)
25
+ trackio.init = _trackio_safe(trackio.init)
26
+ trackio.finish = _trackio_safe(trackio.finish)
27
+
28
+ from huggingface_hub import HfApi, snapshot_download
29
+ from safetensors.torch import load_file, save_file
30
+ from sklearn.metrics import f1_score
31
+ from sklearn.model_selection import train_test_split
32
+ from transformers import AutoTokenizer
33
+
34
+ import laya
35
+ from laya.agent import _fix_tokenizer_config
36
+ from laya.common import QTYPES, build_model, build_sequence, proper_reward, render_options
37
+
38
+ SMOKE = os.environ.get("SMOKE", "0") == "1"
39
+ MODEL_ID = "convaiinnovations/laya"
40
+ OUTPUT_DIR = os.environ.get("LAYA_OUTPUT_DIR", "/data/laya_vulnerability_groups")
41
+ PARQUET_URL = "https://huggingface.co/datasets/GIZ/vulnerability_training_data_full/resolve/refs%2Fconvert%2Fparquet/default/train/0000.parquet"
42
+ LABELS = [
43
+ "Agricultural communities", "Coastal communities", "Ethnic, racial or other minorities",
44
+ "Fishery communities", "Informal sector workers", "Members of indigenous and local communities",
45
+ "Migrants and displaced persons", "Older persons", "Other", "Persons living in poverty",
46
+ "Persons with disabilities", "Persons with pre-existing health conditions",
47
+ "Residents of drought-prone regions", "Rural populations", "Sexual minorities (LGBTQI+)",
48
+ "Urban populations", "Women and other genders",
49
+ ]
50
+ REPO_ID = "peter2000/laya-vulnerability-groups"
51
+ QIDS = [f"g{i}" for i in range(len(LABELS))]
52
+
53
+ def make_questions():
54
+ return {
55
+ qid: {
56
+ "type": "noul",
57
+ "instructions": f"Does this text indicate that {label} are targeted, supported, or affected as a vulnerable group? Answer true or false.",
58
+ }
59
+ for qid, label in zip(QIDS, LABELS)
60
+ }
61
+
62
+ QUESTIONS = make_questions()
63
+
64
+ def load_data():
65
+ df = pd.read_parquet(PARQUET_URL)
66
+ assert len(df) == 475, f"expected 475 rows, got {len(df)}"
67
+ Y = df[LABELS].values.astype(np.int64)
68
+ nlab = Y.sum(1)
69
+ idx_tr, idx_te = train_test_split(
70
+ np.arange(len(df)), test_size=0.2, random_state=42, stratify=np.minimum(nlab, 3)
71
+ )
72
+ return df, Y, np.asarray(idx_tr), np.asarray(idx_te)
73
+
74
+ def ece(conf, correct, n_bins=15):
75
+ conf = np.asarray(conf, dtype=np.float64)
76
+ corr = np.asarray(correct, dtype=np.float64)
77
+ bins = np.linspace(0.0, 1.0, n_bins + 1)
78
+ e = 0.0
79
+ for lo, hi in zip(bins[:-1], bins[1:]):
80
+ m = (conf > lo) & (conf <= hi)
81
+ if m.sum() > 0:
82
+ e += m.mean() * abs(corr[m].mean() - conf[m].mean())
83
+ return float(e)
84
+
85
+ def evaluate(Y_true, P_pred, threshold=0.5):
86
+ pred = (P_pred >= threshold).astype(int)
87
+ per_label = f1_score(Y_true, pred, average=None, zero_division=0)
88
+ macro = float(f1_score(Y_true, pred, average="macro", zero_division=0))
89
+ micro = float(f1_score(Y_true, pred, average="micro", zero_division=0))
90
+ conf = np.where(pred == 1, P_pred, 1.0 - P_pred)
91
+ corr = (pred == Y_true).astype(np.float64)
92
+ return {
93
+ "macro_f1": macro,
94
+ "micro_f1": micro,
95
+ "ece": ece(conf, corr),
96
+ "subset_accuracy": float(((pred == Y_true).all(axis=1)).mean()),
97
+ "per_label_f1": {LABELS[i]: round(float(per_label[i]), 4) for i in range(len(LABELS))},
98
+ }
99
+
100
+ def probs_from_answers(res):
101
+ return np.array([res["answers"][qid]["noul"] for qid in QIDS], dtype=np.float64)
102
+
103
+ def eval_agent(agent, texts, Y_true):
104
+ t0 = time.time()
105
+ P = np.stack([probs_from_answers(agent.predict(t, QUESTIONS)) for t in texts])
106
+ metrics = evaluate(Y_true, P)
107
+ metrics["eval_seconds"] = round(time.time() - t0, 1)
108
+ return metrics
109
+
110
+ def collate_train_batch(items, pad_id):
111
+ n, L = len(items), max(len(it["ids"]) for it in items)
112
+ kmax = max(len(it["markers"]) for it in items)
113
+ ids = torch.full((n, L), pad_id, dtype=torch.long)
114
+ att = torch.zeros((n, L), dtype=torch.long)
115
+ mpos = torch.zeros((n, kmax), dtype=torch.long)
116
+ mmask = torch.zeros((n, kmax), dtype=torch.bool)
117
+ target = torch.zeros((n, kmax), dtype=torch.float32)
118
+ for i, it in enumerate(items):
119
+ ids[i, : len(it["ids"])] = torch.tensor(it["ids"])
120
+ att[i, : len(it["ids"])] = 1
121
+ k = len(it["markers"])
122
+ mpos[i, :k] = torch.tensor(it["markers"])
123
+ mmask[i, :k] = True
124
+ target[i, : len(it["target"])] = torch.tensor(it["target"], dtype=torch.float32)
125
+ return {
126
+ "input_ids": ids, "attention_mask": att, "marker_pos": mpos,
127
+ "marker_mask": mmask, "target": target,
128
+ "qtype": torch.tensor([it["qtype"] for it in items]),
129
+ "label": torch.tensor([it["label"] for it in items]),
130
+ }
131
+
132
+ def fit_one_temp(sel):
133
+ if len(sel) < 10:
134
+ return 1.0
135
+ kmax = max(len(z) for z, _ in sel)
136
+ Z = torch.full((len(sel), kmax), -1e4)
137
+ T = torch.zeros((len(sel), kmax))
138
+ for i, (z, t) in enumerate(sel):
139
+ Z[i, :len(z)] = torch.tensor(z)
140
+ T[i, :len(t)] = torch.tensor(t, dtype=torch.float32)
141
+ log_t = torch.zeros(1, requires_grad=True)
142
+ opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
143
+ def closure():
144
+ opt.zero_grad()
145
+ loss = -(T * torch.log_softmax(Z / log_t.exp(), -1)).sum(-1).mean()
146
+ loss.backward()
147
+ return loss
148
+ opt.step(closure)
149
+ return float(torch.clamp(log_t.exp(), 0.1, 10.0).item())
150
+
151
+ def build_items(texts, yvecs, tok, cfg):
152
+ items, dropped = [], 0
153
+ k_expected = len(render_options({"t": "noul", "crit": {}}))
154
+ for text, yvec in zip(texts, yvecs):
155
+ for j in range(len(LABELS)):
156
+ p_true = float(yvec[j])
157
+ target = [1.0 - p_true, p_true]
158
+ q = {"t": "noul", "ins": QUESTIONS[QIDS[j]]["instructions"], "crit": {}}
159
+ seq, markers = build_sequence(tok, text, q, cfg["max_len"], cfg["head_max_len"])
160
+ if len(markers) != k_expected:
161
+ dropped += 1
162
+ continue
163
+ items.append({
164
+ "ids": seq, "markers": markers, "qtype": QTYPES["noul"],
165
+ "target": target, "label": j,
166
+ })
167
+ print(f"built {len(items)} items, dropped {dropped} (marker mismatch), k_expected={k_expected}")
168
+ return items
169
+
170
+ def train(items, model, tok, cfg, device):
171
+ EPOCHS = 1 if SMOKE else 4
172
+ MICRO_BATCH = 8
173
+ GRAD_ACCUM = 4
174
+ GROUP_SIZE = 4
175
+ LR_ENCODER = 2.5e-5
176
+ LR_HEAD = 1.0e-4
177
+ SIGMA_START = 0.4
178
+ SIGMA_END = 0.1
179
+
180
+ enc_params = [p for n, p in model.named_parameters() if "encoder." in n]
181
+ head_params = [p for n, p in model.named_parameters() if "encoder." not in n]
182
+ optimizer = torch.optim.AdamW([
183
+ {"params": enc_params, "lr": LR_ENCODER},
184
+ {"params": head_params, "lr": LR_HEAD},
185
+ ], weight_decay=0.01)
186
+ total_updates = max(1, (len(items) // (MICRO_BATCH * GRAD_ACCUM)) * EPOCHS)
187
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=total_updates, eta_min=1e-6)
188
+ scaler = torch.amp.GradScaler("cuda", enabled=True)
189
+
190
+ pad_id = tok.pad_token_id
191
+ t0 = time.time()
192
+ for epoch in range(EPOCHS):
193
+ random.seed(42 + epoch)
194
+ random.shuffle(items)
195
+ epoch_loss, n_batches = 0.0, 0
196
+ optimizer.zero_grad(set_to_none=True)
197
+ accum_step = 0
198
+ progress = epoch / max(1, EPOCHS - 1)
199
+ sigma = SIGMA_START + (SIGMA_END - SIGMA_START) * progress
200
+
201
+ for b_idx in range(0, len(items), MICRO_BATCH):
202
+ chunk = items[b_idx:b_idx + MICRO_BATCH]
203
+ if not chunk:
204
+ continue
205
+ batch = collate_train_batch(chunk, pad_id)
206
+ with torch.autocast("cuda", dtype=torch.float16):
207
+ logits, act = model(
208
+ batch["input_ids"].to(device),
209
+ batch["attention_mask"].to(device),
210
+ batch["marker_pos"].to(device),
211
+ batch["marker_mask"].to(device),
212
+ batch["qtype"].to(device),
213
+ )
214
+ logits = logits.float()
215
+ mask = batch["marker_mask"].to(device)
216
+ k = mask.sum(-1, keepdim=True).float()
217
+ target = batch["target"].to(device)
218
+
219
+ eps = torch.randn((GROUP_SIZE,) + logits.shape, device=device) * sigma * mask
220
+ eps = (eps - eps.sum(-1, keepdim=True) / k) * mask
221
+ z = logits.detach().unsqueeze(0) + eps
222
+ q = torch.softmax(z.masked_fill(~mask, -1e4), -1)
223
+
224
+ with torch.no_grad():
225
+ r = proper_reward(q, target.unsqueeze(0), batch["qtype"].to(device), mask, w_sph=0.75, w_rps=1.0)
226
+ adv = r - r.mean(0, keepdim=True)
227
+ adv = adv / (adv.std() + 1e-6)
228
+
229
+ logp = -(((z - logits.unsqueeze(0)) ** 2) * mask).sum(-1) / (2 * sigma ** 2)
230
+ loss_rl = -(adv * logp).mean()
231
+ loss_ce = -(target * torch.log_softmax(logits.masked_fill(~mask, -1e4), -1)).sum(-1).mean()
232
+ loss = (loss_rl + 1.0 * loss_ce) / GRAD_ACCUM + 0.0 * act.sum()
233
+
234
+ scaler.scale(loss).backward()
235
+ accum_step += 1
236
+ if accum_step % GRAD_ACCUM == 0 or (b_idx + MICRO_BATCH) >= len(items):
237
+ scaler.unscale_(optimizer)
238
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
239
+ scaler.step(optimizer)
240
+ scaler.update()
241
+ scheduler.step()
242
+ optimizer.zero_grad(set_to_none=True)
243
+
244
+ epoch_loss += loss.item() * GRAD_ACCUM
245
+ n_batches += 1
246
+ if n_batches % 50 == 0:
247
+ print(f" epoch {epoch+1}/{EPOCHS} step {n_batches} loss {loss.item()*GRAD_ACCUM:.4f} reward {r.mean().item():.3f} lr {scheduler.get_last_lr()[0]:.2e}")
248
+ trackio.log({"laya_loss": loss.item() * GRAD_ACCUM, "laya_reward": r.mean().item()}, step=epoch * 100000 + n_batches)
249
+
250
+ print(f"=== epoch {epoch+1}/{EPOCHS} done in {time.time()-t0:.0f}s avg_loss {epoch_loss/max(1,n_batches):.4f} ===")
251
+ trackio.log({"laya_epoch_loss": epoch_loss / max(1, n_batches)}, step=epoch + 1)
252
+
253
+ ckpt_dir = os.path.join(OUTPUT_DIR, "checkpoint_latest")
254
+ os.makedirs(ckpt_dir, exist_ok=True)
255
+ ckpt_sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
256
+ save_file(ckpt_sd, os.path.join(ckpt_dir, "model.safetensors"))
257
+ model.encoder.config.save_pretrained(os.path.join(ckpt_dir, "encoder"))
258
+ tok.save_pretrained(os.path.join(ckpt_dir, "tokenizer"))
259
+ with open(os.path.join(ckpt_dir, "checkpoint_meta.json"), "w") as f:
260
+ json.dump({"epoch": epoch + 1, "total_epochs": EPOCHS, "avg_loss": epoch_loss / max(1, n_batches)}, f, indent=2)
261
+
262
+ del optimizer, scaler, scheduler
263
+ torch.cuda.empty_cache()
264
+ return model
265
+
266
+ def fit_and_apply_temperature(items, model, cfg, device, pad_id):
267
+ model.eval()
268
+ calib_items = items[::15][:400]
269
+ calib_preds = []
270
+ with torch.no_grad():
271
+ for c_idx in range(0, len(calib_items), 16):
272
+ c_chunk = calib_items[c_idx:c_idx + 16]
273
+ cb = collate_train_batch(c_chunk, pad_id)
274
+ with torch.autocast("cuda", dtype=torch.float16):
275
+ l_sub, _ = model(
276
+ cb["input_ids"].to(device),
277
+ cb["attention_mask"].to(device),
278
+ cb["marker_pos"].to(device),
279
+ cb["marker_mask"].to(device),
280
+ cb["qtype"].to(device),
281
+ )
282
+ l_np = l_sub.float().cpu().numpy()
283
+ for rr, it in enumerate(c_chunk):
284
+ k = len(it["markers"])
285
+ calib_preds.append((it["qtype"], l_np[rr, :k], it["target"]))
286
+
287
+ fitted = [1.2, 1.2, 1.2]
288
+ for qt in range(3):
289
+ sel = [(z, t) for q_type, z, t in calib_preds if q_type == qt]
290
+ if sel:
291
+ fitted[qt] = fit_one_temp(sel)
292
+ print("fitted calibration temperatures (choice, score, noul):", [round(t, 3) for t in fitted])
293
+ cfg["temperature"] = fitted
294
+ if isinstance(cfg.get("temperature_by_options"), dict):
295
+ cfg["temperature_by_options"]["noul:2"] = fitted[QTYPES["noul"]]
296
+ return fitted
297
+
298
+ def save_checkpoint(model, tok, cfg):
299
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
300
+ sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
301
+ save_file(sd, os.path.join(OUTPUT_DIR, "model.safetensors"))
302
+ model.encoder.config.save_pretrained(os.path.join(OUTPUT_DIR, "encoder"))
303
+ tok.save_pretrained(os.path.join(OUTPUT_DIR, "tokenizer"))
304
+ cfg["fine_tuned"] = True
305
+ cfg["model_name"] = "laya-vulnerability-groups"
306
+ with open(os.path.join(OUTPUT_DIR, "rl_agent_config.json"), "w") as f:
307
+ json.dump(cfg, f, indent=2)
308
+ ckpt_dir = os.path.join(OUTPUT_DIR, "checkpoint_latest")
309
+ if os.path.isdir(ckpt_dir):
310
+ shutil.rmtree(ckpt_dir)
311
+ print(f"checkpoint saved to {OUTPUT_DIR}")
312
+
313
+ def write_readme(metrics):
314
+ readme = f"""---
315
+ license: apache-2.0
316
+ library_name: transformers
317
+ tags: [laya, text-classification, multi-label, climate, vulnerability, rlcd]
318
+ pipeline_tag: text-classification
319
+ ---
320
+
321
+ # Laya fine-tuned for climate-vulnerability group detection (multi-label)
322
+
323
+ [convaiinnovations/laya](https://huggingface.co/convaiinnovations/laya) (421M, ModernBERT-large backbone)
324
+ fine-tuned with the official RLCD recipe on [GIZ/vulnerability_training_data_full](https://huggingface.co/datasets/GIZ/vulnerability_training_data_full)
325
+ (380 train rows x 17 binary vulnerability-group questions; 36 all-negative rows included as negatives).
326
+
327
+ Each vulnerability group is asked as one binary (`noul`) typed question; all 17 are answered in a single forward pass.
328
+ Evaluate with `laya.load("peter2000/laya-vulnerability-groups")` and `agent.predict(state, questions)`.
329
+
330
+ ## Test-set metrics (held-out {95 if not SMOKE else 5} rows, threshold 0.5)
331
+
332
+ | metric | value |
333
+ |---|---|
334
+ | macro-F1 | {metrics['macro_f1']:.4f} |
335
+ | micro-F1 | {metrics['micro_f1']:.4f} |
336
+ | ECE | {metrics['ece']:.4f} |
337
+ | subset accuracy | {metrics['subset_accuracy']:.4f} |
338
+
339
+ Per-label F1:
340
+
341
+ | label | F1 |
342
+ |---|---|
343
+ """
344
+ for label, f1 in metrics["per_label_f1"].items():
345
+ readme += f"| {label} | {f1:.4f} |\n"
346
+ with open(os.path.join(OUTPUT_DIR, "README.md"), "w") as f:
347
+ f.write(readme)
348
+
349
+ def main():
350
+ df, Y, idx_tr, idx_te = load_data()
351
+ texts = df["text"].tolist()
352
+ X_tr = [texts[i] for i in idx_tr]
353
+ X_te = [texts[i] for i in idx_te]
354
+ Y_tr, Y_te = Y[idx_tr], Y[idx_te]
355
+ if SMOKE:
356
+ X_te, Y_te = X_te[:5], Y_te[:5]
357
+ print(f"train={len(X_tr)} test={len(X_te)} labels={len(LABELS)}")
358
+
359
+ trackio.init(project="vulnerability-multilabel-classifier", space_id="peter2000/vulnerability-multilabel-classifier-trackio")
360
+
361
+ model_dir = snapshot_download(MODEL_ID, ignore_patterns=["multilingual/*", "typed-decisions/*", "assets/*", "eval/*", "*.py"])
362
+ _fix_tokenizer_config(model_dir)
363
+ tok = AutoTokenizer.from_pretrained(os.path.join(model_dir, "tokenizer"))
364
+ with open(os.path.join(model_dir, "rl_agent_config.json")) as f:
365
+ cfg = json.load(f)
366
+ cfg["gradient_checkpointing"] = True
367
+
368
+ device = "cuda"
369
+
370
+ print("--- zero-shot baseline eval ---")
371
+ agent0 = laya.load(model_dir, device=device)
372
+ m0 = eval_agent(agent0, X_te, Y_te)
373
+ print("zero-shot:", json.dumps(m0, indent=2))
374
+ trackio.log({"laya_zeroshot_macro_f1": m0["macro_f1"], "laya_zeroshot_micro_f1": m0["micro_f1"], "laya_zeroshot_ece": m0["ece"]}, step=0)
375
+ del agent0
376
+ torch.cuda.empty_cache()
377
+
378
+ items = build_items(X_tr, Y_tr, tok, cfg)
379
+ if SMOKE:
380
+ items = items[:64]
381
+
382
+ print("building model...")
383
+ model = build_model(cfg, encoder_dir=os.path.join(model_dir, "encoder"))
384
+ model.load_state_dict(load_file(os.path.join(model_dir, "model.safetensors")), strict=True)
385
+ model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
386
+ model.head_checkpointing = True
387
+ model.to(device)
388
+ model.train()
389
+
390
+ train(items, model, tok, cfg, device)
391
+
392
+ fitted = fit_and_apply_temperature(items, model, cfg, device, tok.pad_token_id)
393
+ print("fitted temperatures:", [round(t, 3) for t in fitted])
394
+
395
+ save_checkpoint(model, tok, cfg)
396
+
397
+ print("--- fine-tuned eval ---")
398
+ agent_ft = laya.load(OUTPUT_DIR, device=device)
399
+ m1 = eval_agent(agent_ft, X_te, Y_te)
400
+ print("fine-tuned:", json.dumps(m1, indent=2))
401
+ trackio.log({"laya_macro_f1": m1["macro_f1"], "laya_micro_f1": m1["micro_f1"], "laya_ece": m1["ece"]}, step=10)
402
+ trackio.log({f"laya_f1/{k}": v for k, v in m1["per_label_f1"].items()}, step=10)
403
+
404
+ write_readme(m1)
405
+ api = HfApi(token=os.environ.get("HF_TOKEN"))
406
+ api.upload_folder(
407
+ folder_path=OUTPUT_DIR,
408
+ repo_id=REPO_ID,
409
+ repo_type="model",
410
+ commit_message=f"Laya fine-tuned on GIZ vulnerability data: macro-F1 {m1['macro_f1']:.3f} (zero-shot {m0['macro_f1']:.3f})",
411
+ )
412
+ api.upload_file(
413
+ path_or_fileobj=json.dumps({"zero_shot": m0, "fine_tuned": m1}, indent=2).encode(),
414
+ path_in_repo="metrics.json",
415
+ repo_id=REPO_ID,
416
+ repo_type="model",
417
+ commit_message="Add evaluation metrics",
418
+ )
419
+ trackio.finish()
420
+ print("DONE")
421
+
422
+ if __name__ == "__main__":
423
+ t0 = time.time()
424
+ main()
425
+ print(f"elapsed {time.time()-t0:.0f}s")