File size: 8,154 Bytes
04a3ade | 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 139 140 141 142 143 | """Shared protocol and I/O for matched random/scaffold checkpoint comparisons."""
from pathlib import Path
import hashlib, json, os, sys, time
AUDIT=Path('/marimo/rem3di_training/corrected_20261027/evaluation/paper_audit')
ROOT=AUDIT.parent.parent
WORK=Path(__file__).resolve().parent.parent
OUT=WORK/'evaluation'
ORIGINAL=AUDIT/'author_checkpoint/exp138/polar'
POST=AUDIT/'author_pcqm500k_5ep'
sys.path[:0]=[str(AUDIT),str(AUDIT/'author_legacy_source'),'/marimo/Rem3Di']
os.environ.update(CUDA_VISIBLE_DEVICES='0',OMP_NUM_THREADS='8',MKL_NUM_THREADS='8',WANDB_MODE='online')
from dotenv import load_dotenv
load_dotenv('/marimo/.env',override=False)
import numpy as np
import torch
torch.serialization.add_safe_globals([slice])
from torch import nn
from torch.nn.utils.rnn import pad_sequence
import yaml
from sklearn.model_selection import KFold,GroupKFold,GroupShuffleSplit,train_test_split
from rdkit import Chem
from rdkit.Chem.Scaffolds import MurckoScaffold
from threedscriptors.configuration.architecture_config import EncoderOnlyArchitectureConfig
from threedscriptors.data_handling.sample import Sample
torch.set_num_threads(8)
SEED=42026
DATASETS=['esol','freesolv']
CHECKPOINTS=list(range(1,11))
SPLITS=['random','scaffold']
PROTOCOL={
'checkpoints':CHECKPOINTS,'initial_checkpoint':'Selected PCQM epoch 2, followed by 10 COSMO epochs',
'datasets':DATASETS,'outer_cv':'3-fold; random KFold(shuffle=True, random_state=42026), scaffold GroupKFold by achiral Bemis-Murcko scaffold',
'scaffold_empty':'All acyclic molecules share the empty scaffold; kept together, so scaffold folds can be unequal.',
'ridge_probe':'StandardScaler + Ridge; alpha selected on 3 inner folds from 1e-6..1e6 (13 values); group-disjoint inner CV for scaffold',
'kumo':'Kumo-Tabular large, installed sdm version; 4 estimators; all 640 descriptors; seed=16000+fold; default recipe with width limit raised to 640',
'finetuning':{'max_epochs':100,'patience':15,'backbone_lr':1e-5,'head_lr':1e-3,'weight_decay':1e-4,'grad_clip':1.,
'head':'640 -> 256 -> 1, ReLU, dropout 0.2','validation_fraction':0.2,'scaffold_validation':'Closest to 20% of rows among 512 seeded scaffold-group prefix partitions; no labels used',
'trainable':'checkpoint encoder + learned atomic/chiral and geometric preprocessors + regression head; MACE frozen',
'selection':'Best inner validation RMSE; outer test evaluated once after selection; no outer test-based tuning',
'scaling':'Descriptor mean/std and target mean/std fit on inner training rows only; checkpoint atomic normalization buffers preserved',
'seeds':'One matched initialization/shuffle seed per outer fold, identical across checkpoints',
'refit':'No refit on validation rows after early stopping; report actual train/validation counts'},
'metrics':['rmse','mae','r2'],'total_probe_fold_results':240,'total_finetuning_fold_results':120,
'comparison':'Identical saved row indices across checkpoints and methods; probes use all outer training rows; finetuning reserves inner validation.',
'dataset_scope':'Existing retained/geometry-valid subsets: ESOL 1108 and FreeSolv 625; no new filtering.',
}
def write(path,value):
path.parent.mkdir(parents=True,exist_ok=True)
tmp=path.with_suffix(path.suffix+'.partial')
tmp.write_text(json.dumps(value,indent=2,allow_nan=False));tmp.replace(path)
def sha(path):
h=hashlib.sha256()
with path.open('rb') as f:
for block in iter(lambda:f.read(8<<20),b''):h.update(block)
return h.hexdigest()
def status(phase,**kw):
write(OUT/'status.json',dict(phase=phase,pid=os.getpid(),updated_unix=time.time(),**kw))
print('STATUS',phase,json.dumps(kw),flush=True)
def checkpoint(epoch):return WORK/'checkpoints'/f'epoch_{epoch:02d}'
def load_backbone(epoch):
d=yaml.safe_load((ORIGINAL/'post_training_architecture_config.yaml').read_text())
d.pop('decoder_config',None);d['kind']='encoder_only';d['mace_config']=None
cfg=EncoderOnlyArchitectureConfig.model_validate(d)
pre=cfg._build_preprocessor();enc=cfg._build_encoder()
# Cast before loading to preserve the trained double-precision chiral weights.
pre.atomic_preprocessor.double();pre.geometric_preprocessor.float();enc.float()
for name,module in [('encoder.pth',enc),('atomic_preprocessor.pth',pre.atomic_preprocessor),('geometric_preprocessor.pth',pre.geometric_preprocessor)]:
module.load_state_dict(torch.load(checkpoint(epoch)/name,map_location='cpu',weights_only=True),strict=True)
return pre.cuda(),enc.cuda()
def arrays(dataset):
z=np.load(ROOT/'evaluation/cache'/f'{dataset}.npz')
y=z['y'][:,0].astype(np.float64);smiles=z['smiles']
assert np.isfinite(y).all()
canonical=[Chem.MolToSmiles(Chem.MolFromSmiles(str(s)),isomericSmiles=True) for s in smiles]
assert len(set(canonical))==len(canonical), 'Duplicate canonical molecules require grouped random splitting'
groups=np.array([MurckoScaffold.MurckoScaffoldSmiles(mol=Chem.MolFromSmiles(str(s)),includeChirality=False) for s in smiles])
return y,smiles,groups
def prepare_splits():
for dataset in DATASETS:
y,smiles,groups=arrays(dataset);indices=np.arange(len(y))
for scheme in SPLITS:
path=OUT/'splits'/f'{dataset}_{scheme}.json'
folds=[]
outer=KFold(3,shuffle=True,random_state=SEED).split(indices) if scheme=='random' else GroupKFold(3).split(indices,groups=groups)
for fold,(tr,te) in enumerate(outer):
if scheme=='random':fit,val=train_test_split(tr,test_size=.2,random_state=16000+fold)
else:
unique,counts=np.unique(groups[tr],return_counts=True)
rng=np.random.default_rng(16000+fold);candidates=[]
for _ in range(512):
order=rng.permutation(len(unique));k=int(np.argmin(abs(np.cumsum(counts[order])-.2*len(tr))))+1
mask=np.isin(groups[tr],unique[order[:k]])
it,iv=np.flatnonzero(~mask),np.flatnonzero(mask)
if len(iv)>=10 and len(it)>=20:candidates.append((it,iv))
assert candidates, 'No useful scaffold validation partition'
it,iv=min(candidates,key=lambda pair:abs(len(pair[1])/len(tr)-.2))
fit,val=tr[it],tr[iv]
assert not set(tr)&set(te) and set(fit)|set(val)==set(tr) and not set(fit)&set(val)
if scheme=='scaffold':
assert not set(groups[tr])&set(groups[te])
assert not set(groups[fit])&set(groups[val])
folds.append(dict(fold=fold,train=tr.tolist(),test=te.tolist(),fit=fit.tolist(),validation=val.tolist()))
assert sorted(i for f in folds for i in f['test'])==indices.tolist()
result=dict(dataset=dataset,scheme=scheme,smiles_sha256=hashlib.sha256('\n'.join(smiles).encode()).hexdigest(),
n=len(y),n_scaffolds=len(set(groups)),folds=folds)
if path.exists() and (OUT/'results.csv').exists():assert json.loads(path.read_text())==result, 'Saved split protocol changed'
else:write(path,result)
print('SPLITS',dataset,scheme,[(len(f['fit']),len(f['validation']),len(f['test'])) for f in folds],flush=True)
def get_splits(dataset,scheme):return json.loads((OUT/'splits'/f'{dataset}_{scheme}.json').read_text())['folds']
def batch_indices(indices,lengths,shuffle=None,max_size=48,atom_cap=4000):
order=np.asarray(indices).copy()
if shuffle is not None:shuffle.shuffle(order)
# Sorting within buckets controls padding; random bucket membership changes each epoch.
for start in range(0,len(order),256):
chunk=sorted(order[start:start+256],key=lambda i:lengths[i]);batch=[];atoms=0
for i in chunk:
if batch and (atoms+lengths[i]>atom_cap or len(batch)>=max_size):
yield batch;batch=[];atoms=0
batch.append(int(i));atoms+=lengths[i]
if batch:yield batch
def sample(ids,data):
f=pad_sequence([data['features'][i] for i in ids],batch_first=True).cuda()
p=pad_sequence([data['positions'][i] for i in ids],batch_first=True).cuda()
lengths=torch.tensor([data['lengths'][i] for i in ids],device='cuda')
mask=torch.arange(f.shape[1],device='cuda')[None]>=lengths[:,None]
return Sample(embeddings=f,atomic_positions=p,padding_mask=mask)
def metric(y,p):
from sklearn.metrics import mean_squared_error,mean_absolute_error,r2_score
assert np.isfinite(p).all()
return dict(rmse=float(np.sqrt(mean_squared_error(y,p))),mae=float(mean_absolute_error(y,p)),r2=float(r2_score(y,p)))
|