enzyme-retrieval-bert / predict.py
Da24b048's picture
Final ensemble: 4 all-data BERTs + kNN 0.1 + MLP 0.3
307cc2f verified
Raw History Blame Contribute Delete
6.15 kB
"""Offline CPU inference: ensemble of fine-tuned reaction BERTs (int8) blended with a TF-IDF reaction kNN.
Output: top-5 distinct ECs per unique input, best first (only the first is scored)."""
import argparse
import json
import re
from pathlib import Path
import numpy as np
import pandas as pd
import scipy.sparse as sp
import torch
import torch.nn as nn
from transformers import BertConfig, BertModel
import featurize as F
from inputs import prepare_inputs
SMI_RE = re.compile(r"(\[[^\]]+]|Br?|Cl?|N|O|S|P|F|I|b|c|n|o|s|p|\(|\)|\.|=|#|-|\+|\\|/|:|~|@|\?|>>?|\*|\$|%[0-9]{2}|[0-9])")
MAX_LEN = 256
TOP = 5
class Net(nn.Module):
def __init__(self, config, n_cls, n_aux):
super().__init__()
self.bert = BertModel(config, add_pooling_layer=False)
self.head = nn.Linear(config.hidden_size, n_cls)
self.aux = nn.ModuleList([nn.Linear(config.hidden_size, n) for n in n_aux])
def forward(self, ids, mask):
x = self.bert(input_ids=ids, attention_mask=mask).last_hidden_state
m = mask.unsqueeze(-1).to(x.dtype)
return self.head((x * m).sum(1) / m.sum(1))
def bert_probs(wdir, rxns, all_idx, quantize):
"""Softmax probabilities over the shared class list (all_idx maps this model's classes into it)."""
classes = json.loads((wdir / 'classes.json').read_text())
tokens = (wdir / 'vocab.txt').read_text().splitlines()
idx = {t: i for i, t in enumerate(tokens)}
state = torch.load(wdir / 'best.pt', map_location='cpu', weights_only=True)
n_aux = [state[f'aux.{i}.weight'].shape[0] for i in range(3)] if 'aux.0.weight' in state else []
net = Net(BertConfig.from_pretrained(wdir), len(classes), n_aux)
net.load_state_dict(state, strict=False)
net.eval()
if quantize:
net = torch.quantization.quantize_dynamic(net, {nn.Linear}, dtype=torch.qint8)
enc = [[idx['[CLS]']] + [idx.get(t, 1) for t in SMI_RE.findall(r)][:MAX_LEN - 2] + [idx['[SEP]']] for r in rxns]
order = sorted(range(len(enc)), key=lambda i: len(enc[i]))
out = np.zeros((len(rxns), len(all_idx[1])), np.float32)
with torch.inference_mode():
for a in range(0, len(order), 64):
b = order[a:a + 64]
n = max(len(enc[i]) for i in b)
ids = torch.zeros(len(b), n, dtype=torch.long)
mask = torch.zeros(len(b), n, dtype=torch.long)
for j, i in enumerate(b):
ids[j, :len(enc[i])] = torch.tensor(enc[i]); mask[j, :len(enc[i])] = 1
p = torch.softmax(net(ids, mask).float(), -1).numpy()
for j, i in enumerate(b):
out[i, all_idx[0]] = p[j]
return out
def knn_probs(kdir, Q, n, all_cls, k=10, power=8):
X = sp.load_npz(kdir / 'X.npz').tocsr()
Y = sp.load_npz(kdir / 'Y.npz').tocsr()
kc = json.loads((kdir / 'classes.json').read_text())
XT = X.T.tocsr()
out = np.zeros((n, len(kc)), np.float32)
for a in range(0, n, 250):
S = (Q[a:a + 250] @ XT).toarray()
ik = np.argpartition(-S, k, axis=1)[:, :k]
sk = np.maximum(np.take_along_axis(S, ik, 1), 0) ** power
for j in range(k):
out[a:a + 250] += sk[:, j:j + 1] * Y[ik[:, j]].toarray()
out /= np.maximum(out.sum(1, keepdims=True), 1e-9)
pos = {e: i for i, e in enumerate(all_cls)}
full = np.zeros((n, len(all_cls)), np.float32)
for i, e in enumerate(kc):
if e in pos:
full[:, pos[e]] = out[:, i]
return full
def mlp_probs(mdir, Q, all_cls):
"""Fingerprint MLP (Linear-ReLU-Linear) on the same TF-IDF features as the kNN."""
st = torch.load(mdir / 'mlp.pt', map_location='cpu', weights_only=True)
cl = json.loads((mdir / 'classes.json').read_text())
net = nn.Sequential(nn.Linear(st['0.weight'].shape[1], st['0.weight'].shape[0]), nn.ReLU(), nn.Dropout(0.0),
nn.Linear(st['3.weight'].shape[1], st['3.weight'].shape[0]))
net.load_state_dict({k: v.float() for k, v in st.items()})
net.eval()
pos = {e: i for i, e in enumerate(all_cls)}
idx = [pos[e] for e in cl]
full = np.zeros((Q.shape[0], len(all_cls)), np.float32)
with torch.inference_mode():
for a in range(0, Q.shape[0], 512):
full[a:a + 512][:, idx] = torch.softmax(net(torch.from_numpy(Q[a:a + 512].toarray())), -1).numpy()
return full
def predict_file(input_path, output_path):
torch.set_num_threads(2)
root = Path(__file__).parent
w = root / 'weights'
cfg = json.loads((w / 'blend.json').read_text())
frame = prepare_inputs(pd.read_parquet(input_path))
rxns = frame.rxn_smiles.tolist()
valid = set(json.loads((root / 'ec_vocabulary.json').read_text()))
kdir = w / 'knn'
all_cls = json.loads((kdir / 'classes.json').read_text())
pos = {e: i for i, e in enumerate(all_cls)}
probs = 0
models = sorted(d for d in w.iterdir() if d.is_dir() and (d / 'best.pt').exists())
for d in models:
cl = json.loads((d / 'classes.json').read_text())
probs = probs + bert_probs(d, rxns, ([pos[e] for e in cl], all_cls), cfg['quantize'])
probs = probs / len(models)
kw, mw = cfg['knn_weight'], cfg.get('mlp_weight', 0.0)
idf = np.load(kdir / 'idf.npz')
whole, diff = F.raw_features(rxns)
Q = F.combine(whole, diff, idf['whole'], idf['diff'])
blend = (1 - kw - mw) * probs + kw * knn_probs(kdir, Q, len(rxns), all_cls)
if mw:
blend = blend + mw * mlp_probs(w / 'mlp', Q, all_cls)
probs = blend
top = np.argsort(-probs, axis=1)[:, :TOP]
with Path(output_path).open('w') as out:
for key, row in zip(frame.id, top):
p = [all_cls[t] for t in row if all_cls[t] in valid]
out.write(json.dumps({'id': key, 'prediction': p or [all_cls[0]]}) + '\n')
if __name__ == '__main__':
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--input', required=True)
ap.add_argument('--output', required=True)
a = ap.parse_args()
predict_file(a.input, a.output)