File size: 6,311 Bytes
2fb9b1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547ab08
 
 
2fb9b1a
 
 
547ab08
 
 
 
 
 
 
2fb9b1a
 
 
 
 
cea9038
2fb9b1a
cea9038
 
2fb9b1a
 
 
 
 
 
 
 
cea9038
2fb9b1a
 
 
 
 
 
 
 
 
cea9038
 
 
 
 
2fb9b1a
cea9038
 
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
"""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, with_std=False):
    """Mean-pooled last-layer embedding per sequence (special tokens excluded).
    Sequences longer than `window` are split into windows whose embeddings are averaged.
    with_std=True also returns the per-dimension standard deviation over residues (aura-6)."""
    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)
    sd = np.zeros_like(out) if with_std else None
    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
            m = m.unsqueeze(-1).float(); cnt = m.sum(1)
            mu = (h * m).sum(1) / cnt
            out[idx] = mu.cpu().numpy()
            if with_std:
                sd[idx] = (((h - mu.unsqueeze(1)) ** 2 * m).sum(1) / cnt).sqrt().cpu().numpy()
    owner = np.array(owner)
    pool = lambda a: np.stack([a[owner == i].mean(0) for i in range(len(seqs))])
    return (pool(out), pool(sd)) if with_std else pool(out)