Download features.py from VNVSH-DA24B034/aura: direct link, hf CLI and curl.
- Browser
- Download file 5.92 kB
-
https://huggingface.co/VNVSH-DA24B034/aura/resolve/main/features.py
- Command line
-
hf download hf://VNVSH-DA24B034/aura/features.py
-
curl -L -o features.py https://huggingface.co/VNVSH-DA24B034/aura/resolve/main/features.py
5.92 kB
| """Feature extraction shared by train.py and model.py (must be identical at train and inference time).""" | |
| from itertools import product | |
| import numpy as np | |
| import pandas as pd | |
| from rdkit import Chem, RDLogger | |
| from rdkit.Chem import Descriptors, MACCSkeys, rdFingerprintGenerator | |
| RDLogger.DisableLog('rdApp.*') | |
| TARGETS = ['log10_kcat', 'log10_km'] | |
| # ---------------- enzyme: handcrafted composition features (489) ---------------- | |
| AA = 'ACDEFGHIKLMNPQRSTVWY' | |
| DIP = {a + b: i for i, (a, b) in enumerate(product(AA, AA))} | |
| KD = dict(A=1.8, R=-4.5, N=-3.5, D=-3.5, C=2.5, Q=-3.5, E=-3.5, G=-0.4, H=-3.2, I=4.5, L=3.8, K=-3.9, | |
| M=1.9, F=2.8, P=-1.6, S=-0.8, T=-0.7, W=-0.9, Y=-1.3, V=4.2) | |
| AA_GROUPS = ['AVLIMFWC', 'STNQGPY', 'KRH', 'DE', 'FWYH', 'AGSCTP'] | |
| def seq_feats(seq): | |
| s = ''.join(c for c in seq.upper() if c in AA) or 'A' | |
| L = len(s) | |
| aac = np.array([s.count(a) for a in AA]) / L | |
| dc = np.zeros(400) | |
| for i in range(L - 1): | |
| dc[DIP[s[i:i + 2]]] += 1 | |
| dc /= max(1, L - 1) | |
| kd = np.array([KD[c] for c in s]) | |
| grp = [sum(s.count(c) for c in g) / L for g in AA_GROUPS] | |
| thirds = [np.array([p.count(a) for a in AA]) / max(1, len(p)) | |
| for p in (s[:L // 3], s[L // 3:2 * L // 3], s[2 * L // 3:])] | |
| return np.concatenate([[np.log(L), kd.mean(), kd.std()], grp, aac, dc, *thirds]).astype(np.float32) | |
| # ---------------- substrate: RDKit features (2048 Morgan counts + 167 MACCS + descriptors) ---------------- | |
| _MORGAN = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048) | |
| N_SMI = 2048 + 167 + len(Descriptors._descList) | |
| def smi_feats(smi): | |
| m = Chem.MolFromSmiles(smi) | |
| if m is None: | |
| return np.full(N_SMI, np.nan, dtype=np.float32) | |
| fp = np.asarray(_MORGAN.GetCountFingerprintAsNumPy(m), dtype=np.float32) | |
| mac = np.array(list(MACCSkeys.GenMACCSKeys(m)), dtype=np.float32) | |
| d = [] | |
| for _, f in Descriptors._descList: | |
| try: | |
| d.append(f(m)) | |
| except Exception: | |
| d.append(np.nan) | |
| d = np.nan_to_num(np.array(d, dtype=np.float64), nan=np.nan, posinf=1e6, neginf=-1e6).clip(-1e6, 1e6) | |
| return np.concatenate([fp, mac, d]).astype(np.float32) | |
| def base_features(sequences, smiles): | |
| """Enzyme + substrate features, computed once per unique string.""" | |
| S = {s: seq_feats(s) for s in pd.unique(sequences)} | |
| M = {s: smi_feats(s) for s in pd.unique(smiles)} | |
| return np.hstack([np.stack([S[s] for s in sequences]), np.stack([M[s] for s in smiles])]) | |
| # ---------------- substrate prior: statistics of each substrate's labels in the training data ---------------- | |
| PRIOR_K = 5 # shrinkage: (n*mean + K*global_mean) / (n + K) | |
| def prior_table(smiles, Y): | |
| """Per-substrate label statistics from training rows -> DataFrame indexed by SMILES.""" | |
| d = pd.DataFrame(Y, columns=TARGETS) | |
| g = d.groupby(np.asarray(smiles)) | |
| m, n = g.mean(), g.size() | |
| gm = Y.mean(0) | |
| tab = (m.mul(n, axis=0) + gm * PRIOR_K).div(n + PRIOR_K, axis=0).add_prefix('shrunk_') | |
| tab['log_count'] = np.log1p(n) | |
| for agg in ['std', 'max', 'min', 'median']: | |
| tab = tab.join(getattr(g, agg)().add_prefix(agg + '_')) | |
| tab.attrs['global_mean'] = gm.tolist() | |
| return tab | |
| def prior_features(tab, smiles, global_mean): | |
| """Look up prior features; unseen substrates get the global mean, count 0 and NaN spread stats.""" | |
| out = tab.reindex(np.asarray(smiles)).to_numpy(dtype=np.float64).copy() | |
| unseen = np.isnan(out[:, 0]) | |
| out[unseen, 0], out[unseen, 1], out[unseen, 2] = global_mean[0], global_mean[1], 0.0 | |
| return out | |
| # ---------------- enzyme: ESM-2 protein language model embeddings (optional, v2) ---------------- | |
| def load_esm(model_dir, device='cpu', quantize=False, layer=None): | |
| """layer=k keeps only the first k transformer layers and drops the final LayerNorm, so last_hidden_state equals | |
| hidden_states[k] of the full model (an intermediate layer) and inference is faster.""" | |
| import torch | |
| from transformers import AutoTokenizer, EsmModel | |
| tok = AutoTokenizer.from_pretrained(model_dir) | |
| model = EsmModel.from_pretrained(model_dir, add_pooling_layer=False) | |
| if layer: | |
| model.encoder.layer = model.encoder.layer[:layer] | |
| model.encoder.emb_layer_norm_after = None | |
| model.contact_head = None # unused for embeddings; its size depends on the layer count | |
| model.config.num_hidden_layers = layer | |
| model = model.to(device).eval() | |
| if quantize: # int8 weights for Linear layers: ~1.6x faster on CPU, embeddings ~0.999 cosine to fp32 | |
| model = torch.ao.quantization.quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8) | |
| return tok, model | |
| def esm_embed(seqs, tok, model, device='cpu', batch_tokens=2000, window=1022): | |
| """Mean-pooled last-layer embedding per sequence (special tokens excluded). | |
| Sequences longer than `window` are split into windows whose embeddings are averaged.""" | |
| import torch | |
| pieces, owner = [], [] | |
| for i, s in enumerate(seqs): | |
| s = s.upper() | |
| for j in range(0, max(1, len(s)), window): | |
| pieces.append(s[j:j + window]); owner.append(i) | |
| order = np.argsort([len(p) for p in pieces])[::-1] | |
| out = np.zeros((len(pieces), model.config.hidden_size), dtype=np.float32) | |
| k = 0 | |
| with torch.no_grad(): | |
| while k < len(order): | |
| n = max(1, batch_tokens // (len(pieces[order[k]]) + 2)) | |
| idx = order[k:k + n]; k += n | |
| enc = tok([pieces[i] for i in idx], return_tensors='pt', padding=True).to(device) | |
| h = model(**enc).last_hidden_state.float() | |
| m = enc['attention_mask'].clone(); m[:, 0] = 0 | |
| m[torch.arange(len(idx)), enc['attention_mask'].sum(1) - 1] = 0 | |
| out[idx] = ((h * m.unsqueeze(-1)).sum(1) / m.sum(1, keepdim=True)).cpu().numpy() | |
| owner = np.array(owner) | |
| return np.stack([out[owner == i].mean(0) for i in range(len(seqs))]) | |