peter2000 commited on
Commit
b86e40c
·
verified ·
1 Parent(s): f1c8dea

v2: label-aware climate-context instructions; otherwise identical RLCD recipe to v1

Browse files
Files changed (1) hide show
  1. train_laya.py +430 -0
train_laya.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_v2")
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-v2"
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? This group is specifically vulnerable to climate change. 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
+ metrics["probabilities"] = P.round(4).tolist()
109
+ return metrics
110
+
111
+ def collate_train_batch(items, pad_id):
112
+ n, L = len(items), max(len(it["ids"]) for it in items)
113
+ kmax = max(len(it["markers"]) for it in items)
114
+ ids = torch.full((n, L), pad_id, dtype=torch.long)
115
+ att = torch.zeros((n, L), dtype=torch.long)
116
+ mpos = torch.zeros((n, kmax), dtype=torch.long)
117
+ mmask = torch.zeros((n, kmax), dtype=torch.bool)
118
+ target = torch.zeros((n, kmax), dtype=torch.float32)
119
+ for i, it in enumerate(items):
120
+ ids[i, : len(it["ids"])] = torch.tensor(it["ids"])
121
+ att[i, : len(it["ids"])] = 1
122
+ k = len(it["markers"])
123
+ mpos[i, :k] = torch.tensor(it["markers"])
124
+ mmask[i, :k] = True
125
+ target[i, : len(it["target"])] = torch.tensor(it["target"], dtype=torch.float32)
126
+ return {
127
+ "input_ids": ids, "attention_mask": att, "marker_pos": mpos,
128
+ "marker_mask": mmask, "target": target,
129
+ "qtype": torch.tensor([it["qtype"] for it in items]),
130
+ "label": torch.tensor([it["label"] for it in items]),
131
+ }
132
+
133
+ def fit_one_temp(sel):
134
+ if len(sel) < 10:
135
+ return 1.0
136
+ kmax = max(len(z) for z, _ in sel)
137
+ Z = torch.full((len(sel), kmax), -1e4)
138
+ T = torch.zeros((len(sel), kmax))
139
+ for i, (z, t) in enumerate(sel):
140
+ Z[i, :len(z)] = torch.tensor(z)
141
+ T[i, :len(t)] = torch.tensor(t, dtype=torch.float32)
142
+ log_t = torch.zeros(1, requires_grad=True)
143
+ opt = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
144
+ def closure():
145
+ opt.zero_grad()
146
+ loss = -(T * torch.log_softmax(Z / log_t.exp(), -1)).sum(-1).mean()
147
+ loss.backward()
148
+ return loss
149
+ opt.step(closure)
150
+ return float(torch.clamp(log_t.exp(), 0.1, 10.0).item())
151
+
152
+ def build_items(texts, yvecs, tok, cfg):
153
+ items, dropped = [], 0
154
+ k_expected = len(render_options({"t": "noul", "crit": {}}))
155
+ for text, yvec in zip(texts, yvecs):
156
+ for j in range(len(LABELS)):
157
+ p_true = float(yvec[j])
158
+ target = [1.0 - p_true, p_true]
159
+ q = {"t": "noul", "ins": QUESTIONS[QIDS[j]]["instructions"], "crit": {}}
160
+ seq, markers = build_sequence(tok, text, q, cfg["max_len"], cfg["head_max_len"])
161
+ if len(markers) != k_expected:
162
+ dropped += 1
163
+ continue
164
+ items.append({
165
+ "ids": seq, "markers": markers, "qtype": QTYPES["noul"],
166
+ "target": target, "label": j,
167
+ })
168
+ print(f"built {len(items)} items, dropped {dropped} (marker mismatch), k_expected={k_expected}")
169
+ return items
170
+
171
+ def train(items, model, tok, cfg, device):
172
+ EPOCHS = 1 if SMOKE else 4
173
+ MICRO_BATCH = 8
174
+ GRAD_ACCUM = 4
175
+ GROUP_SIZE = 4
176
+ LR_ENCODER = 2.5e-5
177
+ LR_HEAD = 1.0e-4
178
+ SIGMA_START = 0.4
179
+ SIGMA_END = 0.1
180
+
181
+ enc_params = [p for n, p in model.named_parameters() if "encoder." in n]
182
+ head_params = [p for n, p in model.named_parameters() if "encoder." not in n]
183
+ optimizer = torch.optim.AdamW([
184
+ {"params": enc_params, "lr": LR_ENCODER},
185
+ {"params": head_params, "lr": LR_HEAD},
186
+ ], weight_decay=0.01)
187
+ total_updates = max(1, (len(items) // (MICRO_BATCH * GRAD_ACCUM)) * EPOCHS)
188
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=total_updates, eta_min=1e-6)
189
+ scaler = torch.amp.GradScaler("cuda", enabled=True)
190
+
191
+ pad_id = tok.pad_token_id
192
+ t0 = time.time()
193
+ for epoch in range(EPOCHS):
194
+ random.seed(42 + epoch)
195
+ random.shuffle(items)
196
+ epoch_loss, n_batches = 0.0, 0
197
+ optimizer.zero_grad(set_to_none=True)
198
+ accum_step = 0
199
+ progress = epoch / max(1, EPOCHS - 1)
200
+ sigma = SIGMA_START + (SIGMA_END - SIGMA_START) * progress
201
+
202
+ for b_idx in range(0, len(items), MICRO_BATCH):
203
+ chunk = items[b_idx:b_idx + MICRO_BATCH]
204
+ if not chunk:
205
+ continue
206
+ batch = collate_train_batch(chunk, pad_id)
207
+ with torch.autocast("cuda", dtype=torch.float16):
208
+ logits, act = model(
209
+ batch["input_ids"].to(device),
210
+ batch["attention_mask"].to(device),
211
+ batch["marker_pos"].to(device),
212
+ batch["marker_mask"].to(device),
213
+ batch["qtype"].to(device),
214
+ )
215
+ logits = logits.float()
216
+ mask = batch["marker_mask"].to(device)
217
+ k = mask.sum(-1, keepdim=True).float()
218
+ target = batch["target"].to(device)
219
+
220
+ eps = torch.randn((GROUP_SIZE,) + logits.shape, device=device) * sigma * mask
221
+ eps = (eps - eps.sum(-1, keepdim=True) / k) * mask
222
+ z = logits.detach().unsqueeze(0) + eps
223
+ q = torch.softmax(z.masked_fill(~mask, -1e4), -1)
224
+
225
+ with torch.no_grad():
226
+ r = proper_reward(q, target.unsqueeze(0), batch["qtype"].to(device), mask, w_sph=0.75, w_rps=1.0)
227
+ adv = r - r.mean(0, keepdim=True)
228
+ adv = adv / (adv.std() + 1e-6)
229
+
230
+ logp = -(((z - logits.unsqueeze(0)) ** 2) * mask).sum(-1) / (2 * sigma ** 2)
231
+ loss_rl = -(adv * logp).mean()
232
+ loss_ce = -(target * torch.log_softmax(logits.masked_fill(~mask, -1e4), -1)).sum(-1).mean()
233
+ loss = (loss_rl + 1.0 * loss_ce) / GRAD_ACCUM + 0.0 * act.sum()
234
+
235
+ scaler.scale(loss).backward()
236
+ accum_step += 1
237
+ if accum_step % GRAD_ACCUM == 0 or (b_idx + MICRO_BATCH) >= len(items):
238
+ scaler.unscale_(optimizer)
239
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
240
+ scaler.step(optimizer)
241
+ scaler.update()
242
+ scheduler.step()
243
+ optimizer.zero_grad(set_to_none=True)
244
+
245
+ epoch_loss += loss.item() * GRAD_ACCUM
246
+ n_batches += 1
247
+ if n_batches % 50 == 0:
248
+ 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}")
249
+ trackio.log({"laya_loss": loss.item() * GRAD_ACCUM, "laya_reward": r.mean().item()}, step=epoch * 100000 + n_batches)
250
+
251
+ print(f"=== epoch {epoch+1}/{EPOCHS} done in {time.time()-t0:.0f}s avg_loss {epoch_loss/max(1,n_batches):.4f} ===")
252
+ trackio.log({"laya_epoch_loss": epoch_loss / max(1, n_batches)}, step=epoch + 1)
253
+
254
+ ckpt_dir = os.path.join(OUTPUT_DIR, "checkpoint_latest")
255
+ os.makedirs(ckpt_dir, exist_ok=True)
256
+ ckpt_sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
257
+ save_file(ckpt_sd, os.path.join(ckpt_dir, "model.safetensors"))
258
+ model.encoder.config.save_pretrained(os.path.join(ckpt_dir, "encoder"))
259
+ tok.save_pretrained(os.path.join(ckpt_dir, "tokenizer"))
260
+ with open(os.path.join(ckpt_dir, "checkpoint_meta.json"), "w") as f:
261
+ json.dump({"epoch": epoch + 1, "total_epochs": EPOCHS, "avg_loss": epoch_loss / max(1, n_batches)}, f, indent=2)
262
+
263
+ del optimizer, scaler, scheduler
264
+ torch.cuda.empty_cache()
265
+ return model
266
+
267
+ def fit_and_apply_temperature(items, model, cfg, device, pad_id):
268
+ model.eval()
269
+ calib_items = items[::15][:400]
270
+ calib_preds = []
271
+ with torch.no_grad():
272
+ for c_idx in range(0, len(calib_items), 16):
273
+ c_chunk = calib_items[c_idx:c_idx + 16]
274
+ cb = collate_train_batch(c_chunk, pad_id)
275
+ with torch.autocast("cuda", dtype=torch.float16):
276
+ l_sub, _ = model(
277
+ cb["input_ids"].to(device),
278
+ cb["attention_mask"].to(device),
279
+ cb["marker_pos"].to(device),
280
+ cb["marker_mask"].to(device),
281
+ cb["qtype"].to(device),
282
+ )
283
+ l_np = l_sub.float().cpu().numpy()
284
+ for rr, it in enumerate(c_chunk):
285
+ k = len(it["markers"])
286
+ calib_preds.append((it["qtype"], l_np[rr, :k], it["target"]))
287
+
288
+ fitted = [1.2, 1.2, 1.2]
289
+ for qt in range(3):
290
+ sel = [(z, t) for q_type, z, t in calib_preds if q_type == qt]
291
+ if sel:
292
+ fitted[qt] = fit_one_temp(sel)
293
+ print("fitted calibration temperatures (choice, score, noul):", [round(t, 3) for t in fitted])
294
+ cfg["temperature"] = fitted
295
+ if isinstance(cfg.get("temperature_by_options"), dict):
296
+ cfg["temperature_by_options"]["noul:2"] = fitted[QTYPES["noul"]]
297
+ return fitted
298
+
299
+ def save_checkpoint(model, tok, cfg):
300
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
301
+ sd = {k: v.half().contiguous().cpu() for k, v in model.state_dict().items()}
302
+ save_file(sd, os.path.join(OUTPUT_DIR, "model.safetensors"))
303
+ model.encoder.config.save_pretrained(os.path.join(OUTPUT_DIR, "encoder"))
304
+ tok.save_pretrained(os.path.join(OUTPUT_DIR, "tokenizer"))
305
+ cfg["fine_tuned"] = True
306
+ cfg["model_name"] = "laya-vulnerability-groups-v2"
307
+ with open(os.path.join(OUTPUT_DIR, "rl_agent_config.json"), "w") as f:
308
+ json.dump(cfg, f, indent=2)
309
+ ckpt_dir = os.path.join(OUTPUT_DIR, "checkpoint_latest")
310
+ if os.path.isdir(ckpt_dir):
311
+ shutil.rmtree(ckpt_dir)
312
+ print(f"checkpoint saved to {OUTPUT_DIR}")
313
+
314
+ def write_readme(metrics):
315
+ readme = f"""---
316
+ license: apache-2.0
317
+ library_name: transformers
318
+ tags: [laya, text-classification, multi-label, climate, vulnerability, rlcd]
319
+ pipeline_tag: text-classification
320
+ ---
321
+
322
+ # Laya fine-tuned for climate-vulnerability group detection, v2 (multi-label)
323
+
324
+ [convaiinnovations/laya](https://huggingface.co/convaiinnovations/laya) (421M, ModernBERT-large backbone)
325
+ fine-tuned with the official RLCD recipe on [GIZ/vulnerability_training_data_full](https://huggingface.co/datasets/GIZ/vulnerability_training_data_full)
326
+ (380 train rows x 17 binary vulnerability-group questions; 36 all-negative rows included as negatives).
327
+
328
+ **v2 change vs [v1](https://huggingface.co/peter2000/laya-vulnerability-groups):** the typed-question instructions now carry the
329
+ label into context — each binary question reads "Does this text indicate that {label} are targeted, supported, or affected?
330
+ This group is specifically vulnerable to climate change. Answer true or false."
331
+
332
+ Each vulnerability group is asked as one binary (`noul`) typed question; all 17 are answered in a single forward pass.
333
+ Evaluate with `laya.load("peter2000/laya-vulnerability-groups-v2")` and `agent.predict(state, questions)`.
334
+
335
+ ## Test-set metrics (held-out {95 if not SMOKE else 5} rows, threshold 0.5)
336
+
337
+ | metric | value |
338
+ |---|---|
339
+ | macro-F1 | {metrics['macro_f1']:.4f} |
340
+ | micro-F1 | {metrics['micro_f1']:.4f} |
341
+ | ECE | {metrics['ece']:.4f} |
342
+ | subset accuracy | {metrics['subset_accuracy']:.4f} |
343
+
344
+ Per-label F1:
345
+
346
+ | label | F1 |
347
+ |---|---|
348
+ """
349
+ for label, f1 in metrics["per_label_f1"].items():
350
+ readme += f"| {label} | {f1:.4f} |\n"
351
+ with open(os.path.join(OUTPUT_DIR, "README.md"), "w") as f:
352
+ f.write(readme)
353
+
354
+ def main():
355
+ df, Y, idx_tr, idx_te = load_data()
356
+ texts = df["text"].tolist()
357
+ X_tr = [texts[i] for i in idx_tr]
358
+ X_te = [texts[i] for i in idx_te]
359
+ Y_tr, Y_te = Y[idx_tr], Y[idx_te]
360
+ if SMOKE:
361
+ X_te, Y_te = X_te[:5], Y_te[:5]
362
+ print(f"train={len(X_tr)} test={len(X_te)} labels={len(LABELS)}")
363
+
364
+ trackio.init(project="vulnerability-multilabel-classifier", space_id="peter2000/vulnerability-multilabel-classifier-trackio")
365
+
366
+ model_dir = snapshot_download(MODEL_ID, ignore_patterns=["multilingual/*", "typed-decisions/*", "assets/*", "eval/*", "*.py"])
367
+ _fix_tokenizer_config(model_dir)
368
+ tok = AutoTokenizer.from_pretrained(os.path.join(model_dir, "tokenizer"))
369
+ with open(os.path.join(model_dir, "rl_agent_config.json")) as f:
370
+ cfg = json.load(f)
371
+ cfg["gradient_checkpointing"] = True
372
+
373
+ device = "cuda"
374
+
375
+ print("--- zero-shot baseline eval ---")
376
+ agent0 = laya.load(model_dir, device=device)
377
+ m0 = eval_agent(agent0, X_te, Y_te)
378
+ print("zero-shot:", json.dumps({k: v for k, v in m0.items() if k != "probabilities"}, indent=2))
379
+ trackio.log({"laya_zeroshot_macro_f1": m0["macro_f1"], "laya_zeroshot_micro_f1": m0["micro_f1"], "laya_zeroshot_ece": m0["ece"]}, step=0)
380
+ del agent0
381
+ torch.cuda.empty_cache()
382
+
383
+ items = build_items(X_tr, Y_tr, tok, cfg)
384
+ if SMOKE:
385
+ items = items[:64]
386
+
387
+ print("building model...")
388
+ model = build_model(cfg, encoder_dir=os.path.join(model_dir, "encoder"))
389
+ model.load_state_dict(load_file(os.path.join(model_dir, "model.safetensors")), strict=True)
390
+ model.encoder.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
391
+ model.head_checkpointing = True
392
+ model.to(device)
393
+ model.train()
394
+
395
+ train(items, model, tok, cfg, device)
396
+
397
+ fitted = fit_and_apply_temperature(items, model, cfg, device, tok.pad_token_id)
398
+ print("fitted temperatures:", [round(t, 3) for t in fitted])
399
+
400
+ save_checkpoint(model, tok, cfg)
401
+
402
+ print("--- fine-tuned eval ---")
403
+ agent_ft = laya.load(OUTPUT_DIR, device=device)
404
+ m1 = eval_agent(agent_ft, X_te, Y_te)
405
+ print("fine-tuned:", json.dumps({k: v for k, v in m1.items() if k != "probabilities"}, indent=2))
406
+ trackio.log({"laya_macro_f1": m1["macro_f1"], "laya_micro_f1": m1["micro_f1"], "laya_ece": m1["ece"]}, step=10)
407
+ trackio.log({f"laya_f1/{k}": v for k, v in m1["per_label_f1"].items()}, step=10)
408
+
409
+ write_readme(m1)
410
+ api = HfApi(token=os.environ.get("HF_TOKEN"))
411
+ api.upload_folder(
412
+ folder_path=OUTPUT_DIR,
413
+ repo_id=REPO_ID,
414
+ repo_type="model",
415
+ commit_message=f"Laya v2 (climate-context instructions) fine-tuned on GIZ vulnerability data: macro-F1 {m1['macro_f1']:.3f} (zero-shot {m0['macro_f1']:.3f})",
416
+ )
417
+ api.upload_file(
418
+ path_or_fileobj=json.dumps({"zero_shot": {k: v for k, v in m0.items() if k != "probabilities"}, "fine_tuned": {k: v for k, v in m1.items() if k != "probabilities"}}, indent=2).encode(),
419
+ path_in_repo="metrics.json",
420
+ repo_id=REPO_ID,
421
+ repo_type="model",
422
+ commit_message="Add evaluation metrics",
423
+ )
424
+ trackio.finish()
425
+ print("DONE")
426
+
427
+ if __name__ == "__main__":
428
+ t0 = time.time()
429
+ main()
430
+ print(f"elapsed {time.time()-t0:.0f}s")