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)))