Download predict.py from Yashash18/ec-mmseqs-plm-v21: direct link, hf CLI and curl.
- Browser
- Download file 17.9 kB
-
https://huggingface.co/Yashash18/ec-mmseqs-plm-v21/resolve/main/predict.py
- Command line
-
hf download hf://Yashash18/ec-mmseqs-plm-v21/predict.py
-
curl -L -o predict.py https://huggingface.co/Yashash18/ec-mmseqs-plm-v21/resolve/main/predict.py
17.9 kB
| """Offline CPU inference: MMseqs2 search -> (time-budgeted) ESM-2 head on least-confident queries -> LightGBM-style reranker (numpy).""" | |
| import argparse, gzip, json, os, pickle, shutil, subprocess, sys, tempfile, time | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| T0 = time.time() | |
| ROOT = Path(__file__).resolve().parent | |
| WDIR = Path(os.environ.get('EC_WEIGHTS', str(ROOT / 'weights'))) # override only used by our own end-to-end check | |
| sys.path.insert(0, str(ROOT)) | |
| import ecpipe | |
| def log(*a): | |
| print(f'[{time.time() - T0:6.1f}s]', *a, file=sys.stderr, flush=True) | |
| # ---------------------------------------------------------------- diagnostics | |
| def diagnostics(): | |
| try: | |
| import platform | |
| cpu = open('/proc/cpuinfo').read() if os.path.exists('/proc/cpuinfo') else '' | |
| flags = [f for f in ('avx2', 'sse4_1', 'avx512f') if (' ' + f + ' ') in cpu or (' ' + f + '\n') in cpu] | |
| log('python', sys.version.split()[0], '|', platform.platform(), '| cpus', os.cpu_count(), '| uid', os.getuid(), '| cpu flags', flags) | |
| for line in open('/proc/mounts').read().splitlines(): | |
| p = line.split() | |
| if len(p) > 3 and p[1] in ('/', '/tmp', '/dev/shm', '/app', '/var/tmp', '/home'): log('mount', p[1], p[2], p[3][:80]) | |
| for d in ('/tmp', '/dev/shm', '/var/tmp', str(Path.home()), str(ROOT), os.getcwd()): | |
| try: log('dir', d, 'writable' if os.access(d, os.W_OK) else 'NOT writable', 'free GB', round(shutil.disk_usage(d).free / 1e9, 1)) | |
| except Exception as e: log('dir', d, 'err', repr(e)[:80]) | |
| for f in ('/sys/fs/cgroup/memory.max', '/sys/fs/cgroup/memory/memory.limit_in_bytes'): | |
| if os.path.exists(f): log('cgroup mem', open(f).read().strip()); break | |
| mi = open('/proc/meminfo').read().splitlines(); log('meminfo', [x for x in mi if x.startswith(('MemTotal', 'MemAvailable'))]) | |
| for p in sorted(WDIR.rglob('*')): | |
| if p.is_file() and p.stat().st_size > 1e6: log('weights', str(p.relative_to(WDIR)), round(p.stat().st_size / 1e6), 'MB', oct(p.stat().st_mode & 0o777)) | |
| except Exception as e: | |
| log('diagnostics failed', repr(e)[:200]) | |
| # ---------------------------------------------------------------- MMseqs2 | |
| # The grader refuses any file over 16 MiB (that is what broke the first version: copying the 17 MB binary raised EFBIG). | |
| # So: run the binary in place, and search in small chunks so every file MMseqs2 writes stays far below the limit. | |
| MM_CHUNK = int(os.environ.get('EC_MM_CHUNK', 400)) # queries per MMseqs2 call | |
| MM_DEADLINE_S = 260 # no new first-pass MMseqs2 chunk starts after this many seconds | |
| MM2_DEADLINE_S = 330 # no new second-pass (sensitive) chunk starts after this many seconds | |
| EMBED_DEADLINE_S = 460 # PLM embedding stops here | |
| KNN_DEADLINE_S = 530 # embedding-space nearest-neighbour search stops here | |
| MOTIF_DEADLINE_S = 520 # full-sequence motif scoring stops here (features become missing) | |
| class Runner: | |
| """Finds a way to run the bundled static mmseqs binary: in place (chmod if needed) first, copies only as a last resort.""" | |
| def __init__(self): | |
| self.cmd0, self.fds, self.how = None, (), None | |
| try: cpu = open('/proc/cpuinfo').read() | |
| except Exception: cpu = '' | |
| names = ['mmseqs-avx2', 'mmseqs-sse41'] if ' avx2' in cpu else ['mmseqs-sse41', 'mmseqs-avx2'] | |
| for name in names: | |
| src = WDIR / 'bin' / name | |
| if not src.exists(): continue | |
| if self._try(lambda: self._inplace(src), f'{name} in place'): return | |
| for d in ('/tmp', '/dev/shm', '/var/tmp', str(Path.home()), str(ROOT), os.getcwd()): | |
| if self._try(lambda d=d: self._copy(src, d), f'{name} copied to {d}'): return | |
| log('NO RUNNABLE mmseqs binary found') | |
| def _inplace(self, src): | |
| if not os.access(src, os.X_OK): os.chmod(src, 0o755) | |
| return [str(src)], () | |
| def _copy(self, src, d): | |
| dst = Path(tempfile.mkdtemp(dir=d)) / 'mmseqs'; shutil.copy(src, dst); os.chmod(dst, 0o755); return [str(dst)], () | |
| def _try(self, make, label): | |
| try: | |
| cmd0, fds = make() | |
| r = subprocess.run(cmd0 + ['version'], capture_output=True, text=True, timeout=60, pass_fds=fds) | |
| if r.returncode == 0: | |
| self.cmd0, self.fds, self.how = cmd0, fds, label; log('mmseqs runnable:', label, r.stdout.strip()[:30]); return True | |
| log('mmseqs not runnable:', label, 'rc', r.returncode, (r.stdout + r.stderr)[-200:].strip()) | |
| except Exception as e: | |
| log('mmseqs not runnable:', label, repr(e)[:200]) | |
| return False | |
| def run(self, args, timeout): | |
| r = subprocess.run(self.cmd0 + args, capture_output=True, text=True, timeout=max(20, timeout), pass_fds=self.fds) | |
| if r.returncode: raise RuntimeError(f'rc {r.returncode}: ' + (r.stdout + r.stderr)[-700:]) | |
| return r | |
| def pick_workdir(need_gb=0.3): | |
| for d in ('/tmp', '/dev/shm', '/var/tmp', str(Path.home()), str(ROOT), os.getcwd()): | |
| try: | |
| if os.access(d, os.W_OK) and shutil.disk_usage(d).free > need_gb * 1e9: return tempfile.mkdtemp(dir=d) | |
| except Exception: pass | |
| return tempfile.mkdtemp() | |
| RUNNER = None | |
| def parse_m8(path): | |
| if not os.path.exists(path) or os.path.getsize(path) == 0: return {} | |
| m8 = pd.read_csv(path, sep='\t', header=None, names=['q', 't', 'bits', 'pid', 'qc', 'tc']) | |
| if os.environ.get('EC_HIT_CAP'): m8 = m8[m8.pid < float(os.environ['EC_HIT_CAP'])] # only for the notebook's end-to-end check on an identity-capped holdout | |
| m8 = m8.sort_values(['q', 'bits'], ascending=[True, False], kind='stable') | |
| q = m8.q.values; cut = np.flatnonzero(np.diff(q)) + 1; starts = np.r_[0, cut]; ends = np.r_[cut, len(q)] | |
| cols = [m8[c].values for c in ('t', 'bits', 'pid', 'qc', 'tc')] | |
| return {int(q[s]): tuple(c[s:e] for c in cols) for s, e in zip(starts, ends)} | |
| def mm_hits(seqs, meta, deadline, idxs=None, s=None, max_seqs=None, chunk=None, tag='mmseqs', budget=None): | |
| """-> {query index: (target idx, bits, pident, qcov, tcov) sorted by bits desc}. Partial results are kept if later chunks fail (reasons are logged).""" | |
| hits = {} | |
| idxs = list(range(len(seqs))) if idxs is None else list(idxs) | |
| s = meta['mm_s'] if s is None else s; max_seqs = meta['mm_max_seqs'] if max_seqs is None else max_seqs; chunk = chunk or MM_CHUNK | |
| try: | |
| rn = RUNNER if RUNNER is not None else Runner() | |
| globals()['RUNNER'] = rn | |
| if rn.cmd0 is None: return {} | |
| db = str(WDIR / 'mm' / 'targetDB'); fails = 0; t_all = time.time(); degraded = False | |
| for st in range(0, len(idxs), chunk): | |
| if time.time() > deadline: log(f'{tag} deadline: stopping at {st}/{len(idxs)}'); break | |
| if budget and st >= 0.25 * len(idxs) and not degraded and (time.time() - t_all) / (st / len(idxs)) > budget: | |
| degraded = True; s = min(float(s), 2.5); max_seqs = min(int(max_seqs), 50) # the grader is slow today: finish the remaining queries with a cheaper search | |
| log(f'{tag}: projected {(time.time() - t_all) / (st / len(idxs)):.0f}s > budget {budget}s, switching to s={s} max-seqs {max_seqs} for the rest') | |
| work = pick_workdir(); t = time.time(); rem = lambda: deadline - time.time() + 60 | |
| try: | |
| with open(work + '/q.fa', 'w') as f: | |
| for i in idxs[st:st + chunk]: f.write('>%d\n%s\n' % (i, seqs[i])) | |
| rn.run(['createdb', work + '/q.fa', work + '/qdb', '-v', '1'], rem()) | |
| rn.run(['search', work + '/qdb', db, work + '/res', work + '/tmp', '-s', str(s), '--max-seqs', str(max_seqs), | |
| '--threads', '2', '--split-memory-limit', '10G', '-v', '1'], rem()) | |
| rn.run(['convertalis', work + '/qdb', db, work + '/res', work + '/res.m8', '--format-output', 'query,target,bits,pident,qcov,tcov', '-v', '1'], rem()) | |
| hits.update(parse_m8(work + '/res.m8')); fails = 0 | |
| except Exception as e: | |
| fails += 1; log(f'MMSEQS chunk at query {st} FAILED ({fails}):', repr(e)[:800]) | |
| if fails >= 3: log('giving up on MMseqs2'); break | |
| finally: | |
| shutil.rmtree(work, ignore_errors=True) | |
| if (st // chunk) % 4 == 0: log(f'{tag} chunk {st // chunk} took {time.time() - t:.1f}s; hits so far {len(hits)}') | |
| log(f'{tag} total {time.time() - t_all:.0f}s') | |
| except Exception as e: | |
| log('MMSEQS FAILED, continuing with what we have:', repr(e)[:900]) | |
| return hits | |
| # ---------------------------------------------------------------- ESM-2 (ONNX) + head | |
| def embed_queries(seqs, order, meta, deadline, sess=None): | |
| """Mean-pooled ESM embeddings (raw, not normalised) for queries in `order` until `deadline`. -> {query index: vector}""" | |
| if sess is None: | |
| import onnxruntime as ort | |
| so = ort.SessionOptions(); so.intra_op_num_threads = 2; so.inter_op_num_threads = 1 | |
| sess = ort.InferenceSession(str(WDIR / 'esm' / 'model.onnx'), so, providers=['CPUExecutionProvider']) | |
| v = meta['vocab']; unk, cls, eos, pad = v['<unk>'], v['<cls>'], v['<eos>'], v['<pad>'] | |
| maxlen, budget = meta['maxlen'], meta['tok_budget'] | |
| out = {} | |
| for st in range(0, len(order), 96): | |
| if time.time() > deadline: break | |
| blk = sorted(order[st:st + 96], key=lambda i: len(seqs[i])) | |
| i = 0 | |
| while i < len(blk): | |
| j = i + 1 | |
| while j < len(blk) and (j - i + 1) * (min(len(seqs[blk[j]]), maxlen) + 2) <= budget: j += 1 | |
| lst = [[cls] + [v.get(c, unk) for c in seqs[k][:maxlen]] + [eos] for k in blk[i:j]] | |
| L = max(len(x) for x in lst); ids = np.full((len(lst), L), pad, np.int64); am = np.zeros((len(lst), L), np.int64) | |
| for r, x in enumerate(lst): ids[r, :len(x)] = x; am[r, :len(x)] = 1 | |
| hid = sess.run(None, {'input_ids': ids, 'attention_mask': am})[0] | |
| for r, x in enumerate(lst): | |
| if len(x) > 2: out[blk[i + r]] = hid[r, 1:len(x) - 1].mean(0) | |
| i = j | |
| return out | |
| def norm_emb(E, hw): | |
| X = E.astype(np.float32) - hw['MU']; return X / np.maximum(np.linalg.norm(X, axis=1, keepdims=True), 1e-8) | |
| def knn_search(E, keys, hw, path, K=20): | |
| """Cosine nearest neighbours of embedded queries among all training embeddings. -> {query index: (positions, sims)}""" | |
| out = {} | |
| try: | |
| T = np.load(path).astype(np.float32) | |
| for st in range(0, len(keys), 256): | |
| if time.time() > T0 + KNN_DEADLINE_S: log(f'kNN deadline: {st}/{len(keys)} done'); break | |
| ks = keys[st:st + 256]; S = norm_emb(np.stack([E[k] for k in ks]), hw) @ T.T | |
| ix = np.argpartition(-S, K, axis=1)[:, :K]; sv = np.take_along_axis(S, ix, 1); o = np.argsort(-sv, axis=1) | |
| for r, k in enumerate(ks): out[k] = (ix[r][o[r]], sv[r][o[r]]) | |
| except Exception as e: | |
| log('kNN failed, continuing without it:', repr(e)[:400]); out = {} | |
| return out | |
| def head2_and_protos(E, hw2, pw, ctx): | |
| """-> (within-family probabilities, prototype cosine similarities), each (n, n_ECs).""" | |
| X = norm_emb(E, hw2) | |
| Z = ecpipe.gelu(X @ hw2['W1'].T + hw2['b1']) @ hw2['W2'].T + hw2['b2'] | |
| H2 = ecpipe.family_softmax(Z, ctx.l3_id, ctx.nl3).astype(np.float32) | |
| Y = ecpipe.gelu(X @ pw['W1'].T + pw['b1']) @ pw['W2'].T + pw['b2'] | |
| Y = Y / np.maximum(np.linalg.norm(Y, axis=1, keepdims=True), 1e-8) | |
| PS = Y @ pw['P'].astype(np.float32).T; PS[:, ~pw['has']] = np.nan | |
| return H2, PS | |
| def head_probs(E, hw): | |
| X = norm_emb(E, hw) | |
| return ecpipe.softmax(ecpipe.gelu(X @ hw['W1'].T + hw['b1']) @ hw['W2'].T + hw['b2']) | |
| # ---------------------------------------------------------------- main | |
| def main(input_path, output_path): | |
| from inputs import prepare_inputs | |
| diagnostics() | |
| meta = json.load(open(WDIR / 'meta.json')) | |
| raw = pd.read_parquet(input_path) | |
| frame = prepare_inputs(raw) | |
| if 'sequence' not in frame.columns: | |
| frame = frame.merge(raw[['id', 'sequence']].drop_duplicates('id'), on='id') | |
| ids, seqs = frame.id.tolist(), frame.sequence.tolist(); n = len(seqs) | |
| log(f'{n} unique queries') | |
| with gzip.open(WDIR / 'labels.pkl.gz', 'rb') as f: d = pickle.load(f) | |
| ctx = ecpipe.Ctx(d['labels'], d['ecs'], d['prior']) | |
| ctx.use_unseen = bool(meta.get('use_unseen')); log(f'unseen-EC candidates: {ctx.use_unseen} ({sum(len(v) for v in ctx.unseen_by_l3.values())} vocabulary ECs without training sequences)') | |
| if meta.get('use_nb'): | |
| z = np.load(WDIR / 'nb.npz'); ctx.set_nb(z['E'], z['S']); log(f'neighbourhood table {z["E"].shape}') | |
| sj = json.load(open(WDIR / 'stacker.json')) | |
| forests = [ecpipe.Forest(t) for t in sj['forests']] if isinstance(sj, dict) else [ecpipe.Forest(sj)] # an ensemble: the scores are averaged | |
| hits = mm_hits(seqs, meta, deadline=T0 + meta.get('mm_deadline_s', MM_DEADLINE_S), chunk=(MM_CHUNK if meta['mm_max_seqs'] <= 100 else 200), budget=meta.get('mm_budget_s', 230)) | |
| log(f'mmseqs hits for {len(hits)}/{n} queries') | |
| m2 = meta.get('mm2') | |
| if m2: # second, more sensitive pass only for queries without a convincing first-pass hit | |
| weak = [i for i in range(n) if (hits[i][1][0] if i in hits else 0.0) < m2['tb']] | |
| log(f'second pass for {len(weak)} weak queries (s={m2["s"]}, max-seqs {m2["max_seqs"]})') | |
| h2 = mm_hits(seqs, meta, T0 + meta.get('mm2_deadline_s', MM2_DEADLINE_S), idxs=weak, s=m2['s'], max_seqs=m2['max_seqs'], chunk=m2.get('chunk', 200), tag='mmseqs2') | |
| hits.update(h2); log(f'second pass gave hits for {len(h2)}/{len(weak)}; total hits {len(hits)}/{n}') | |
| H, H2, PS = {}, {}, {} | |
| if meta.get('use_plm') and (WDIR / 'esm' / 'model.onnx').exists(): | |
| try: | |
| topbits = np.array([hits[i][1][0] if i in hits else 0.0 for i in range(n)]) | |
| order = [int(i) for i in np.argsort(topbits, kind='stable') if topbits[i] < meta['embed_max_topbits']] | |
| E = embed_queries(seqs, order, meta, deadline=T0 + EMBED_DEADLINE_S) | |
| log(f'embedded {len(E)}/{len(order)} candidate queries') | |
| hw = dict(np.load(WDIR / 'head.npz')) | |
| keys = list(E) | |
| for st in range(0, len(keys), 500): | |
| ks = keys[st:st + 500]; P = head_probs(np.stack([E[k] for k in ks]), hw) | |
| for k, p in zip(ks, P): H[k] = p | |
| if meta.get('use_hier'): | |
| hw2 = dict(np.load(WDIR / 'head2.npz')); pw = dict(np.load(WDIR / 'proto.npz')); pw['has'] = pw['has'].astype(bool) | |
| for st in range(0, len(keys), 500): | |
| ks = keys[st:st + 500]; A2, A3 = head2_and_protos(np.stack([E[k] for k in ks]), hw2, pw, ctx) | |
| for k, a2, a3 in zip(ks, A2, A3): H2[k] = a2; PS[k] = a3 | |
| ctx.use_hier_cands = bool(meta.get('use_hier_cands')) | |
| except Exception as e: | |
| log('PLM path failed, continuing without it:', repr(e)[:500]); H = {}; H2 = {}; PS = {}; E = {}; hw = None | |
| log(f'head probabilities for {len(H)} queries | within-family head and prototypes for {len(H2)}') | |
| KNN = {} | |
| if H and (WDIR / 'knn_emb.npy').exists(): | |
| KNN = knn_search(E, list(H), hw, WDIR / 'knn_emb.npy'); log(f'kNN for {len(KNN)} queries') | |
| MOT = {} | |
| if meta.get('use_motif') and (WDIR / 'motif.npz').exists(): | |
| try: | |
| mfs = meta.get('motif_files') or [dict(file='motif.npz', ks=[3, 4, 5, 6])] # several motif models: probabilities are averaged | |
| mws = [(dict(np.load(WDIR / m['file'])), tuple(m['ks'])) for m in mfs]; t_m = time.time() | |
| for st in range(0, n, 1000): | |
| if time.time() - T0 > MOTIF_DEADLINE_S: log(f'motif deadline: stopping at {st}/{n}'); break | |
| P = np.mean([ecpipe.motif_probs(seqs[st:st + 1000], mw, ks) for mw, ks in mws], axis=0) | |
| for k, p in enumerate(P): MOT[st + k] = p | |
| ctx.use_motif_cands = bool(meta.get('use_motif_cands')) | |
| log(f'motif scores for {len(MOT)}/{n} queries in {time.time() - t_m:.0f}s') | |
| except Exception as e: | |
| log('motif path failed, continuing without it:', repr(e)[:500]); MOT = {} | |
| rows = [] | |
| n2c = 3 if meta.get('nb_cands', True) else 0 # neighbourhood features with or without the two-hop candidates | |
| for i in range(n): rows += ecpipe.query_rows(i, hits.get(i), len(seqs[i]), H.get(i), ctx, knn=KNN.get(i), h2=H2.get(i), ps=PS.get(i), mot=MOT.get(i), TOPN2C=n2c) | |
| log(f'{len(rows)} candidate rows') | |
| best = {} | |
| if rows: | |
| A = np.array(rows, dtype=np.float64) | |
| cols = [ecpipe.COLS.index(f) for f in meta['feats']]; pred = np.mean([f.predict(A[:, cols]) for f in forests], axis=0) | |
| df = pd.DataFrame(dict(q=A[:, 0].astype(int), e=A[:, 1].astype(int), p=pred)) | |
| best = df.loc[df.groupby('q').p.idxmax()].set_index('q').e.to_dict() | |
| vp = ROOT / 'ec_vocabulary.json' | |
| vocab = set(json.loads(vp.read_text())) if vp.exists() else None | |
| with Path(output_path).open('w') as f: | |
| for i, qid in enumerate(ids): | |
| ec = ctx.ecs[int(best[i])] if i in best else ctx.top_ec | |
| if vocab is not None and ec not in vocab: ec = ctx.top_ec if ctx.top_ec in vocab else sorted(vocab)[0] | |
| f.write(json.dumps({'id': qid, 'prediction': [ec]}) + '\n') | |
| log('fallback (no candidates) for', sum(1 for i in range(n) if i not in best), 'of', n, 'queries; done') | |
| if __name__ == '__main__': | |
| ap = argparse.ArgumentParser(description=__doc__) | |
| ap.add_argument('--input', required=True); ap.add_argument('--output', required=True) | |
| a = ap.parse_args(); main(a.input, a.output) | |