aura / features.py
VNVSH-DA24B034's picture
Upload folder using huggingface_hub
547ab08 verified
Raw History Blame Contribute Delete
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))])