File size: 10,272 Bytes
c43c544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""mDeBERTa-v3 cross-encoder: fine-tuned on the ce split (disjoint from stack/val, so its scores are
leak-free features downstream). Resumable training; sharded multi-GPU inference."""
import math
import os
import subprocess
import sys
import time

import numpy as np
import pandas as pd
import torch

from common import Progress, get_logger, gpu_patient, load_json, save_json, save_npy, save_parquet, shard_path
from config import CFG, PATHS, WORK
from prep import load_prep, load_splits
from retrieve_dense import n_shards

log = get_logger("ce")
CE = WORK / "ce"
os.environ.setdefault("HF_HOME", str(PATHS["hf_cache"]))


def _texts(split, side):
    d = load_prep(split, side, ["raw_name", "raw_addr"])
    return (d.raw_name + " | " + d.raw_addr).values


def _amp_dtype():
    """bf16 on Ampere+ (A100/L4/H100, same as the server run); fp16 + loss scaling on T4/V100."""
    return torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16


def _tok_model():
    from transformers import AutoModelForSequenceClassification, AutoTokenizer
    return AutoTokenizer, AutoModelForSequenceClassification


def build_pairs():
    path = CE / "pairs.parquet"
    if path.exists():
        return pd.read_parquet(path)
    sp = load_splits()
    K = load_json(WORK / "reports" / "k_choice.json")["K"]
    parts = []
    for i in range(n_shards("train")):
        d = pd.read_parquet(shard_path("block", "train", i), columns=["q", "c", "y", "rrf_rank"])
        parts.append(d[np.isin(d.q.values, sp["g_ce"]) & (d.rrf_rank < K)])
    d = pd.concat(parts, ignore_index=True)
    rng = np.random.default_rng(CFG["seed"])
    pos = d[d.y == 1]
    neg = d[d.y == 0].sort_values(["q", "rrf_rank"])
    hard = neg.groupby("q").head(CFG["ce_neg_hard"])
    rest = neg.drop(hard.index)
    rnd = rest.assign(r=rng.random(len(rest))).sort_values(["q", "r"]).groupby("q").head(CFG["ce_neg_rand"])
    pairs = pd.concat([pos, hard, rnd[pos.columns]], ignore_index=True)[["q", "c", "y"]]
    pairs = pairs.sample(frac=1.0, random_state=CFG["seed"]).reset_index(drop=True)
    if len(pairs) > CFG["ce_max_pairs"]:          # shared GPU budget: a uniform sample keeps the pos/neg mix
        pairs = pairs.head(CFG["ce_max_pairs"])
    pairs["dev"] = ((pairs.q.values.astype(np.int64) * 2654435761 % 2 ** 32) % 30 == 0)
    save_parquet(pairs, path)
    log.info(f"ce pairs: {len(pairs)} (pos {int(pairs.y.sum())}), dev {int(pairs.dev.sum())}")
    return pairs


def _lr_lambda(total, warm):
    return lambda s: min(1.0, (s + 1) / max(1, warm)) * max(0.0, (total - s) / max(1, total - warm))


def run_ce_train():
    final = CE / "final"
    if (final / "config.json").exists():
        return
    AutoTokenizer, AutoModel = _tok_model()
    pairs = build_pairs()
    tr, dv = pairs[~pairs.dev].reset_index(drop=True), pairs[pairs.dev].reset_index(drop=True)
    ta, tb = _texts("train", "s1"), _texts("train", "pool")
    tok = AutoTokenizer.from_pretrained(CFG["ce_model"])
    # transformers>=5 loads the stored dtype (fp16 for mDeBERTa) by default; fp16 weights + AdamW -> NaN after 1 step
    model = AutoModel.from_pretrained(CFG["ce_model"], num_labels=1, dtype=torch.float32)
    model = gpu_patient(lambda: model.cuda(), log, "load cross-encoder")
    B = CFG["ce_batch"]
    spe = math.ceil(len(tr) / B)
    total = spe * CFG["ce_epochs"]
    if CFG["ce_max_steps"]:
        total = min(total, CFG["ce_max_steps"])
    opt = torch.optim.AdamW(model.parameters(), lr=CFG["ce_lr"], weight_decay=0.01)
    sched = torch.optim.lr_scheduler.LambdaLR(opt, _lr_lambda(total, int(0.05 * total)))
    ck = CE / "ckpt.pt"
    amp = _amp_dtype()
    scaler = torch.amp.GradScaler("cuda", enabled=amp == torch.float16)
    log.info(f"cross-encoder mixed precision: {amp}")
    step = 0
    if ck.exists():
        st = torch.load(ck, map_location="cuda", weights_only=False)
        model.load_state_dict(st["model"]); opt.load_state_dict(st["opt"]); sched.load_state_dict(st["sched"])
        if st.get("scaler") and scaler.is_enabled():
            scaler.load_state_dict(st["scaler"])
        step = st["step"]
        log.info(f"resumed cross-encoder from step {step}/{total}")
    lossf = torch.nn.BCEWithLogitsLoss()
    model.train()
    t0, run = time.time(), []
    perms = {}
    while step < total:
        e, b = divmod(step, spe)
        if e not in perms:
            perms = {e: np.random.default_rng(CFG["seed"] + e).permutation(len(tr))}
        idx = perms[e][b * B:(b + 1) * B]
        q, c, y = tr.q.values[idx], tr.c.values[idx], tr.y.values[idx]
        swap = ((q.astype(np.int64) + c + e) % 2) == 1
        a, bb = ta[q], tb[c]
        x1, x2 = np.where(swap, bb, a).tolist(), np.where(swap, a, bb).tolist()
        try:
            enc = tok(x1, x2, truncation=True, max_length=CFG["ce_max_len"], padding=True, return_tensors="pt").to("cuda")
            with torch.autocast("cuda", dtype=amp):
                logits = model(**enc).logits.squeeze(-1)
            loss = lossf(logits.float(), torch.tensor(y, dtype=torch.float32, device="cuda"))
            scaler.scale(loss).backward()
        except torch.cuda.OutOfMemoryError:     # shared GPU filled up: drop this attempt, wait, redo the step
            opt.zero_grad(set_to_none=True)
            enc = logits = loss = None
            torch.cuda.empty_cache()
            log.warning(f"OOM at step {step}; waiting 60s for the shared GPU")
            time.sleep(60)
            continue
        scaler.unscale_(opt)
        gn = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        if not torch.isfinite(gn) and not scaler.is_enabled():
            raise RuntimeError(f"non-finite gradient at step {step}; refusing to save a corrupted checkpoint")
        scaler.step(opt)                # with fp16 an overflowing step is skipped and the scale lowered
        scaler.update(); sched.step(); opt.zero_grad(set_to_none=True)
        if not all(torch.isfinite(p_).all() for p_ in (model.classifier.weight,)):
            raise RuntimeError(f"weights became non-finite at step {step}")
        step += 1
        run.append(loss.item())
        if step % 100 == 0:
            log.info(f"ce step {step}/{total} loss {np.mean(run[-100:]):.4f} {(time.time() - t0) / 60:.1f}m")
        if step % CFG["ce_ckpt_every"] == 0 or step == total:
            tmp = ck.with_suffix(".tmp")
            torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "sched": sched.state_dict(),
                        "scaler": scaler.state_dict(), "step": step}, tmp)
            os.replace(tmp, ck)
    model.eval()
    p = _score(model, tok, ta[dv.q.values], tb[dv.c.values])
    from sklearn.metrics import roc_auc_score
    auc = roc_auc_score(dv.y.values, p) if dv.y.nunique() > 1 else float("nan")
    tmpd = CE / "final.tmp"
    model.save_pretrained(tmpd); tok.save_pretrained(tmpd)
    os.replace(tmpd, final)
    save_json({"dev_auc": auc, "steps": total, "n_train": len(tr)}, WORK / "reports" / "ce_train.json")
    log.info(f"cross-encoder dev AUC {auc:.5f}")


@torch.no_grad()
def _score(model, tok, a, b, bs=None):
    bs = bs or CFG["ce_infer_batch"]
    order = np.argsort([len(x) + len(y) for x, y in zip(a, b)])
    out = np.empty(len(a), dtype=np.float32)
    i = 0
    while i < len(order):
        idx = order[i:i + bs]
        try:
            enc = tok([a[j] for j in idx], [b[j] for j in idx], truncation=True, max_length=CFG["ce_max_len"],
                      padding=True, return_tensors="pt").to("cuda")
            with torch.autocast("cuda", dtype=_amp_dtype()):
                out[idx] = model(**enc).logits.squeeze(-1).float().cpu().numpy()
            i += bs
        except torch.cuda.OutOfMemoryError:
            torch.cuda.empty_cache()
            bs = max(8, bs // 2)
            log.warning(f"OOM -> infer batch {bs}")
    return out


def _n_ce_shards(split):
    n = len(pd.read_parquet(WORK / "s2" / f"{split}.parquet", columns=["q"]))
    return n, max(1, math.ceil(n / CFG["ce_shard_pairs"]))


def run_ce_infer_worker(split, rank, world):
    AutoTokenizer, AutoModel = _tok_model()
    n, ns = _n_ce_shards(split)
    todo = [i for i in range(ns) if i % world == rank and not shard_path("ce_scores", split, i, "npy").exists()]
    if not todo:
        return
    s2 = pd.read_parquet(WORK / "s2" / f"{split}.parquet", columns=["q", "c"])
    ta, tb = _texts(split, "s1"), _texts(split, "pool")
    tok = AutoTokenizer.from_pretrained(CE / "final")
    model = AutoModel.from_pretrained(CE / "final", dtype=torch.float32)
    model = gpu_patient(lambda: model.cuda().eval(), log, "load cross-encoder")
    prog = Progress(log, len(todo), f"ce infer {split} rank {rank}")
    for i in todo:
        if shard_path("ce_scores", split, i, "npy").exists():   # finished by another worker/helper meanwhile
            prog.step()
            continue
        sl = slice(i * CFG["ce_shard_pairs"], (i + 1) * CFG["ce_shard_pairs"])
        q, c = s2.q.values[sl], s2.c.values[sl]
        save_npy(shard_path("ce_scores", split, i, "npy"), _score(model, tok, ta[q], tb[c]))
        prog.step()


def run_ce_infer(split):
    gpus = [g for g in os.environ.get("M4_ASSIGNED_GPUS", "0").split(",") if g != ""]
    if len(gpus) <= 1:
        run_ce_infer_worker(split, 0, 1)
    else:
        procs = []
        for r, g in enumerate(gpus):
            env = dict(os.environ, CUDA_VISIBLE_DEVICES=g)
            procs.append(subprocess.Popen([sys.executable, "-u", __file__.replace("cross_encoder.py", "stages.py"),
                                           "ce_infer_worker", split, str(r), str(len(gpus))], env=env))
        codes = [p.wait() for p in procs]
        if any(codes):
            raise RuntimeError(f"ce inference workers failed: {codes}")
    n, ns = _n_ce_shards(split)
    missing = [i for i in range(ns) if not shard_path("ce_scores", split, i, "npy").exists()]
    if missing:
        raise RuntimeError(f"missing ce shards {missing[:10]}")


def load_ce_scores(split):
    s2 = pd.read_parquet(WORK / "s2" / f"{split}.parquet", columns=["q", "c"])
    n, ns = _n_ce_shards(split)
    ce = np.concatenate([np.load(shard_path("ce_scores", split, i, "npy")) for i in range(ns)])
    assert len(ce) == len(s2)
    return s2.assign(ce=ce)