"""Shared by training (notebook) and inference (predict.py): candidate features + a numpy tree-ensemble evaluator.""" import json, math import numpy as np FEATS = ['mm_share', 'mm_maxb_ratio', 'mm_maxb', 'mm_cnt', 'mm_rank', 'mm_maxpid', 'mm_maxqcov', 'mm_maxtcov', 'topbits', 'top_pid', 'qlen', 'nhits', 'h_prob', 'h_rank', 'h_max', 'mm_l3share', 'h_l3mass', 'logprior', 'ncand_mm', 'in_top1', 'top3_cnt', 'share_gap', 'share_rank', 'h_rel', 'h_gap', 'top1_nlab', 'sup_nlab', 'tb_per_len', 'mb_per_len', 'q_margin', 'q_entropy', 'q_nclose', 'bits_gap', 'pid_gap', 'cnt_frac', 'maxb_rank', 'k_share', 'k_max', 'k_cnt', 'k_top1', 'n2_share', 'n2_gap', 'n2_rank', 'n_pur', 'n_top1', 'is_unseen', 'fam_nunseen', 'fam_nseen', 'fam_rank', 'hs_prob', 'hs_rank_fam', 'hs_gap_fam', 'ps_sim', 'ps_rank_fam', 'ps_gap_fam', 'ps_gap_all', 'mo_prob', 'mo_rank', 'mo_fam', 'mo_fam_rank', 'mo_gap_fam', 'mo_l3mass'] KNN_FEATS = ['k_share', 'k_max', 'k_cnt', 'k_top1'] CTX_FEATS = ['q_margin', 'q_entropy', 'q_nclose', 'bits_gap', 'pid_gap', 'cnt_frac', 'maxb_rank'] UNSEEN_FEATS = ['is_unseen', 'fam_nunseen', 'fam_nseen', 'fam_rank'] # ECs of the official vocabulary that have no training sequence NB_FEATS = ['n2_share', 'n2_gap', 'n2_rank', 'n_pur', 'n_top1'] # from the training-set neighbourhood graph (precomputed train-vs-train MMseqs2) HS_FEATS = ['hs_prob', 'hs_rank_fam', 'hs_gap_fam'] # within-L3 head: P(4th digit | family) PS_FEATS = ['ps_sim', 'ps_rank_fam', 'ps_gap_fam', 'ps_gap_all'] # supervised-contrastive EC prototypes HIER_FEATS = HS_FEATS + PS_FEATS MOTIF_FEATS = ['mo_prob', 'mo_rank', 'mo_fam', 'mo_fam_rank', 'mo_gap_fam', 'mo_l3mass'] # full-sequence k-mer (3-6) motif classifier; available for EVERY query HEAD_FEATS = ['h_prob', 'h_rank', 'h_max', 'h_l3mass', 'h_rel', 'h_gap'] + KNN_FEATS + HIER_FEATS # all of these need the ESM embedding COLS = ['q', 'e'] + FEATS NAN = float('nan') class Ctx: """labels: list (per target sequence) of tuples of EC indices; ecs: EC strings; prior: per-EC counts.""" def __init__(self, labels, ecs, prior): self.labels, self.ecs = labels, list(ecs) self.prior = np.asarray(prior, float); self.logprior = np.log1p(self.prior) ids = {} self.l3_id = np.array([ids.setdefault('.'.join(e.split('.')[:3]), len(ids)) for e in self.ecs]); self.nl3 = len(ids) self.top_ec = self.ecs[int(self.prior.argmax())] self.nb_e = self.nb_s = None # ECs with no database sequence ("unseen"), grouped by L3 family; ordered by their 4th field (numeric where possible) def key4(e): f = e.split('.')[-1] return (0, int(f), f) if f.isdigit() else (1, 0, f) un = [i for i in range(len(self.ecs)) if self.prior[i] <= 0] self.unseen_by_l3 = {} for i in sorted(un, key=lambda i: key4(self.ecs[i])): self.unseen_by_l3.setdefault(int(self.l3_id[i]), []).append(i) self.fam_members = [np.flatnonzero(self.l3_id == f) for f in range(self.nl3)] self.nunseen_l3 = np.zeros(self.nl3); self.nseen_l3 = np.zeros(self.nl3) for i in range(len(self.ecs)): if self.prior[i] <= 0: self.nunseen_l3[self.l3_id[i]] += 1 else: self.nseen_l3[self.l3_id[i]] += 1 self.use_unseen = False self.use_hier_cands = False self.use_motif_cands = False def set_nb(self, E, S): """E[j], S[j]: the top EC indices among database sequence j's own neighbours and their (bits^2-weighted) shares; S==0 marks empty slots.""" self.nb_e = None if E is None else np.asarray(E); self.nb_s = None if S is None else np.asarray(S, np.float64) def query_rows(qi, hit, qlen, h, ctx, TOPM=50, TOPH=10, knn=None, TOPK=5, TOPN=20, TOPN2C=3, h2=None, ps=None, mot=None, TOPMO=3): """hit: None or (t, bits, pident, qcov, tcov) arrays sorted by bits desc; h: None or head probabilities over all ECs.""" if hit is not None: t, b, pid, qc, tc = (np.asarray(a)[:TOPM] for a in hit); tb = float(b[0]); tpid = float(pid[0]) else: t = b = pid = qc = tc = np.empty(0); tb = 0.0; tpid = 0.0 votes, maxb, cnt, rank, mpid, mqc, mtc = {}, {}, {}, {}, {}, {}, {} nlab = {} for r, (j, bj, pj, qj, tj) in enumerate(zip(t, b, pid, qc, tc)): l = ctx.labels[int(j)] for e in l: votes[e] = votes.get(e, 0.0) + float(bj) ** 2 / len(l); maxb[e] = max(maxb.get(e, 0.0), float(bj)); cnt[e] = cnt.get(e, 0) + 1; rank.setdefault(e, r); nlab[e] = nlab.get(e, 0) + len(l) mpid[e] = max(mpid.get(e, 0.0), float(pj)); mqc[e] = max(mqc.get(e, 0.0), float(qj)); mtc[e] = max(mtc.get(e, 0.0), float(tj)) tot = sum(votes.values()) or 1.0 l3v = {} for e, v in votes.items(): l3v[ctx.l3_id[e]] = l3v.get(ctx.l3_id[e], 0.0) + v / tot if h is not None: l3m = np.bincount(ctx.l3_id, weights=h, minlength=ctx.nl3); hmax = float(h.max()) cands = set(votes) | set(np.argpartition(-h, TOPH)[:TOPH].tolist()) else: l3m = None; hmax = NAN; cands = set(votes) if knn is not None: # nearest training sequences in ESM-embedding space: (positions in ctx.labels, cosine similarities) ki, ks = knn; ks = np.asarray(ks, float); kw = np.exp((ks - ks.max()) * 20.0); kv, km, kc = {}, {}, {} for j, sj, wj in zip(ki, ks, kw): l = ctx.labels[int(j)] for e in l: kv[e] = kv.get(e, 0.0) + wj / len(l); km[e] = max(km.get(e, -1.0), float(sj)); kc[e] = kc.get(e, 0) + 1 ktot = sum(kv.values()) or 1.0; ktop = float(ks.max()); cands |= set(sorted(kv, key=lambda e: -kv[e])[:TOPK]) fam_rank = {} if ctx.use_unseen: # one representative unseen EC per likely L3 family (top-2 by homology share, top-1 by head mass) fams = sorted(l3v, key=lambda f: -l3v[f])[:2] if l3m is not None: fams += [int(np.argmax(l3m))] for r_, f in enumerate(fams): fam_rank.setdefault(int(f), r_) u = ctx.unseen_by_l3.get(int(f)) if u: cands.add(u[0]) use_h2, use_ps = h2 is not None, ps is not None if ctx.use_hier_cands and (use_h2 or use_ps): # best sibling (within-family head / prototype) inside the two most likely families fams2 = sorted(l3v, key=lambda f: -l3v[f])[:2] if l3m is not None: fams2 += [int(np.argmax(l3m))] for f in set(int(x) for x in fams2): mem = ctx.fam_members[f] if use_h2: cands.add(int(mem[int(np.argmax(h2[mem]))])) if use_ps: v = ps[mem] if np.isfinite(v).any(): cands.add(int(mem[int(np.nanargmax(v))])) use_mo = mot is not None if use_mo: mfam = np.bincount(ctx.l3_id, weights=mot, minlength=ctx.nl3) if ctx.use_motif_cands: cands |= set(np.argpartition(-mot, TOPMO)[:TOPMO].tolist()) use_nb = ctx.nb_e is not None n2, pur_v, pur_w, nt1 = {}, {}, {}, None if use_nb and len(t): wsum = 0.0 for r, (j, bj) in enumerate(zip(t[:TOPN], b[:TOPN])): j = int(j); w = float(bj) ** 2; sj = ctx.nb_s[j] if sj[0] <= 0: continue # this database sequence has no neighbours of its own nd = dict(zip(ctx.nb_e[j].tolist(), sj.tolist())); wsum += w if r == 0: nt1 = nd for e, v in nd.items(): if v > 0: n2[e] = n2.get(e, 0.0) + w * v for e in ctx.labels[j]: pur_v[e] = pur_v.get(e, 0.0) + w * nd.get(e, 0.0); pur_w[e] = pur_w.get(e, 0.0) + w if wsum > 0: n2 = {e: v / wsum for e, v in n2.items()} cands |= set(sorted(n2, key=lambda e: -n2[e])[:TOPN2C]) top1 = set(ctx.labels[int(t[0])]) if len(t) else set() top3 = {} for j in t[:3]: for e in ctx.labels[int(j)]: top3[e] = top3.get(e, 0) + 1 shares = {e: votes.get(e, 0.0) / tot for e in cands}; smax = max(shares.values()) if shares else 0.0 srank = {e: r for r, e in enumerate(sorted(shares, key=lambda x: -shares[x]))} mbs = {e: maxb.get(e, 0.0) for e in cands}; mbmax = max(mbs.values()) if mbs else 0.0 mps = {e: mpid.get(e, 0.0) for e in cands}; mpmax = max(mps.values()) if mps else 0.0 brank = {e: r for r, e in enumerate(sorted(mbs, key=lambda x: -mbs[x]))} sv = sorted(shares.values(), reverse=True) qmargin = (sv[0] - (sv[1] if len(sv) > 1 else 0.0)) if sv else 0.0 pp = np.array([v for v in sv if v > 0]); qent = float(-(pp / pp.sum() * np.log(pp / pp.sum())).sum()) if len(pp) else 0.0 nclose = sum(1 for v in sv if sv[0] > 0 and v >= 0.5 * sv[0]); nt = max(len(t), 1) n2c = {e: n2.get(e, 0.0) for e in cands}; n2max = max(n2c.values()) if n2c else 0.0 n2rank = {e: r for r, e in enumerate(sorted(n2c, key=lambda x: -n2c[x]))} mor, mofr, mofg = {}, {}, {} if use_mo: for e in cands: mor[e] = min(int((mot > mot[e]).sum()), 99) for f in set(int(ctx.l3_id[e]) for e in cands): mem = ctx.fam_members[f]; v = mot[mem]; mx = float(v.max()) for e in cands: if int(ctx.l3_id[e]) == f: mofr[e] = int((v > mot[e]).sum()); mofg[e] = (float(mot[e]) - mx) / max(float(mfam[f]), 1e-12) hsr, hsg, psr, psg, psa = {}, {}, {}, {}, {} if use_h2 or use_ps: for f in set(int(ctx.l3_id[e]) for e in cands): mem = ctx.fam_members[f] if use_h2: v = h2[mem]; mx = float(v.max()); order = {int(e): r for r, e in enumerate(mem[np.argsort(-v, kind='stable')])} for e in cands: if int(ctx.l3_id[e]) == f: hsr[e] = order[int(e)]; hsg[e] = float(h2[e]) - mx if use_ps: v = ps[mem]; fin = np.isfinite(v) if fin.any(): mx = float(np.nanmax(v)); vv = np.where(fin, v, -np.inf); order = {int(e): r for r, e in enumerate(mem[np.argsort(-vv, kind='stable')])} for e in cands: if int(ctx.l3_id[e]) == f and np.isfinite(ps[e]): psr[e] = order[int(e)]; psg[e] = float(ps[e]) - mx if use_ps: pc = [float(ps[e]) for e in cands if np.isfinite(ps[e])]; pcmax = max(pc) if pc else NAN rows = [] for e in cands: rows.append((qi, e, votes.get(e, 0.0) / tot, maxb.get(e, 0.0) / tb if tb > 0 else 0.0, maxb.get(e, 0.0), cnt.get(e, 0), rank.get(e, 99), mpid.get(e, 0.0), mqc.get(e, 0.0), mtc.get(e, 0.0), tb, tpid, qlen, len(t), float(h[e]) if h is not None else NAN, int((h > h[e]).sum()) if h is not None else NAN, hmax, l3v.get(ctx.l3_id[e], 0.0), float(l3m[ctx.l3_id[e]]) if h is not None else NAN, float(ctx.logprior[e]), len(votes), int(e in top1), top3.get(e, 0), shares[e] - smax, srank[e], float(h[e]) / hmax if h is not None and hmax > 0 else NAN, float(h[e]) - hmax if h is not None else NAN, len(ctx.labels[int(t[0])]) if len(t) else 0, nlab.get(e, 0) / cnt[e] if cnt.get(e, 0) else 0.0, tb / max(qlen, 1), maxb.get(e, 0.0) / max(qlen, 1), qmargin, qent, nclose, mbs[e] - mbmax, mps[e] - mpmax, cnt.get(e, 0) / nt, brank[e], kv.get(e, 0.0) / ktot if knn is not None else NAN, km.get(e, -1.0) if knn is not None else NAN, kc.get(e, 0) if knn is not None else NAN, ktop if knn is not None else NAN, n2c[e] if use_nb else NAN, n2c[e] - n2max if use_nb else NAN, n2rank[e] if use_nb else NAN, pur_v[e] / pur_w[e] if use_nb and pur_w.get(e, 0) > 0 else NAN, (nt1.get(e, 0.0) if nt1 is not None else NAN) if use_nb else NAN, int(ctx.prior[e] <= 0), float(ctx.nunseen_l3[ctx.l3_id[e]]), float(ctx.nseen_l3[ctx.l3_id[e]]), fam_rank.get(int(ctx.l3_id[e]), 9), float(h2[e]) if use_h2 else NAN, hsr.get(e, NAN) if use_h2 else NAN, hsg.get(e, NAN) if use_h2 else NAN, float(ps[e]) if use_ps else NAN, psr.get(e, NAN) if use_ps else NAN, psg.get(e, NAN) if use_ps else NAN, (float(ps[e]) - pcmax) if use_ps and np.isfinite(ps[e]) else NAN, float(mot[e]) if use_mo else NAN, mor.get(e, NAN) if use_mo else NAN, float(mot[e]) / max(float(mfam[ctx.l3_id[e]]), 1e-12) if use_mo else NAN, mofr.get(e, NAN) if use_mo else NAN, mofg.get(e, NAN) if use_mo else NAN, float(mfam[ctx.l3_id[e]]) if use_mo else NAN)) return rows # ---------- LightGBM model -> arrays -> numpy prediction (no lightgbm needed at inference) ---------- def forest_from_lgb(booster): out = [] for ti in booster.dump_model()['tree_info']: feat, thr, left, right, dl, mnan, val = [], [], [], [], [], [], [] def add(node): k = len(feat); feat.append(-1); thr.append(0.0); left.append(-1); right.append(-1); dl.append(False); mnan.append(False); val.append(0.0) if 'leaf_value' in node: val[k] = float(node['leaf_value']); return k assert node['decision_type'] == '<=' and node['missing_type'] in ('None', 'NaN'), node['missing_type'] feat[k] = int(node['split_feature']); thr[k] = float(node['threshold']); dl[k] = bool(node['default_left']); mnan[k] = node['missing_type'] == 'NaN' left[k] = add(node['left_child']); right[k] = add(node['right_child']); return k add(ti['tree_structure']) out.append(dict(feat=feat, thr=thr, left=left, right=right, dl=dl, mnan=mnan, val=val)) return out class Forest: def __init__(self, trees): self.trees = [{k: np.asarray(v, dtype={'feat': np.int64, 'left': np.int64, 'right': np.int64, 'dl': bool, 'mnan': bool}.get(k, np.float64)) for k, v in t.items()} for t in trees] def predict(self, X): X = np.asarray(X, np.float64); n = len(X); out = np.zeros(n) for tr in self.trees: node = np.zeros(n, np.int64) while True: idx = np.where(tr['feat'][node] >= 0)[0] if len(idx) == 0: break nd = node[idx]; x = X[idx, tr['feat'][nd]]; isn = np.isnan(x) left = np.where(isn, np.where(tr['mnan'][nd], tr['dl'][nd], 0.0 <= tr['thr'][nd]), x <= tr['thr'][nd]) node[idx] = np.where(left, tr['left'][nd], tr['right'][nd]) out += tr['val'][node] return out # ---------- small helpers for the head ---------- def gelu(x): from scipy.special import erf return 0.5 * x * (1.0 + erf(x / math.sqrt(2.0))) def softmax(z): z = z - z.max(1, keepdims=True); e = np.exp(z); return e / e.sum(1, keepdims=True) def family_softmax(z, l3_id, nl3): """z: logits over all ECs, 1-D or (n, n_ECs). -> P(EC | its own L3 family) for every EC.""" z = np.asarray(z, np.float64); one = z.ndim == 1; Z = z[None] if one else z ez = np.exp(Z - Z.max(1, keepdims=True)) S = np.zeros((len(Z), nl3)); np.add.at(S.T, l3_id, ez.T) out = ez / np.maximum(S[:, l3_id], 1e-300) return out[0] if one else out # ---------- full-sequence k-mer motif model (hashed k-mers -> mean embedding -> linear over ECs) ---------- _KLUT = np.full(256, 20, np.int64) for _i, _a in enumerate('ACDEFGHIKLMNPQRSTVWY'): _KLUT[ord(_a)] = _i def kmer_ids(seq, ks=(3, 4, 5, 6), nb=2 ** 20, maxlen=1000): """Hashed ids of every k-mer (k in ks) of the first maxlen residues. Identical in training and inference.""" c = _KLUT[np.frombuffer(seq[:maxlen].encode('ascii', 'ignore'), np.uint8)] out = [] for k in ks: n = len(c) - k + 1 if n <= 0: continue code = np.zeros(n, np.int64) for j in range(k): code = code * 21 + c[j:j + n] out.append((code * 2654435761 + k * 97531) % nb) return np.concatenate(out).astype(np.int64) if out else np.zeros(1, np.int64) def motif_probs(seqs, mw, ks=(3, 4, 5, 6), batch=1000): """mw: dict(table (nb, D) float16, W (nEC, D), b (nEC)). -> (n, nEC) softmax probabilities.""" T = mw['table']; W = mw['W'].astype(np.float32); b = mw['b'].astype(np.float32); nb = T.shape[0] out = np.zeros((len(seqs), len(b)), np.float32) for st in range(0, len(seqs), batch): X = np.stack([T[kmer_ids(s, ks, nb)].astype(np.float32).mean(0) for s in seqs[st:st + batch]]) out[st:st + batch] = softmax(X @ W.T + b) return out