Queimo's picture
Pin exact evaluated starting checkpoint, objective equivalence tests, source and joint training protocol
31dee3b verified
Raw History Blame Contribute Delete
21.6 kB
"""Ten-epoch supervised COSMO continuation of the selected PCQM epoch 2."""
import argparse
import fcntl
import gc
import hashlib
import json
import math
import os
from pathlib import Path
import random
import shutil
import sys
import time
import traceback
WORK = Path(__file__).resolve().parent.parent
AUDIT = Path('/marimo/rem3di_training/corrected_20261027/evaluation/paper_audit')
ORIGINAL = AUDIT/'author_checkpoint/exp138/polar'
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 pandas as pd
import torch
from torch import nn
import yaml
from huggingface_hub import HfApi, hf_hub_download, snapshot_download, CommitOperationAdd
from threedscriptors.configuration.architecture_config import EncoderOnlyArchitectureConfig
from threedscriptors.data_handling.sample import Sample as LegacySample
from remedi.data_handling.sample import Sample, yield_molecules_collate_fn
from build_author_mace_runtime import build_author_mace_runtime
torch.serialization.add_safe_globals([slice])
torch.set_num_threads(8)
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 b in iter(lambda:f.read(8<<20),b''):h.update(b)
return h.hexdigest()
def status(phase,**kw):
row=dict(phase=phase,pid=os.getpid(),updated_unix=time.time(),**kw)
write(WORK/'training_status.json',row)
print('STATUS',json.dumps(row),flush=True)
def backbone(path):
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,enc=cfg._build_preprocessor(),cfg._build_encoder()
# Preserve the learned chiral weights in the checkpoint's original precision.
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(path/name,map_location='cpu',weights_only=True),strict=True)
return pre.cuda(),enc.cuda()
class COSMORegressor(nn.Module):
def __init__(self,pre,enc):
super().__init__();self.pre=pre;self.encoder=enc
self.head=nn.Sequential(nn.Linear(640,256),nn.SiLU(),nn.Linear(256,155)).cuda()
def forward(self,sample):
z=self.encoder(self.pre(sample))
if not torch.is_tensor(z):z=z.flat
return self.head(z)
def batches(indices,lengths,rng=None,max_size=96,atom_cap=4000):
order=np.asarray(indices).copy()
if rng is not None:rng.shuffle(order)
for start in range(0,len(order),1024):
chunk=sorted(order[start:start+1024],key=lambda i:lengths[i]);ids=[];atoms=0
for i in chunk:
if ids and (atoms+lengths[i]>atom_cap or len(ids)>=max_size):
yield ids;ids=[];atoms=0
ids.append(int(i));atoms+=lengths[i]
if ids:yield ids
def inverse(z,mean,scale):
t=z*scale+mean
# Clamp only the metrics' physical reconstruction to the valid range.
return torch.cat([torch.expm1(t[:,:153]).clamp_min(0),torch.exp(t[:,153:])],dim=1)
def loss_terms(pred,target):
e=(pred-target).square()
bins=e[:,:153].mean();area=e[:,153].mean();volume=e[:,154].mean()
return (bins+area+volume)/3,dict(sigma=float(bins.detach()),area=float(area.detach()),volume=float(volume.detach()))
def upload_epoch(api,cfg,path):
marker=path/'hub_verified.json'
if marker.exists():return json.loads(marker.read_text())
names=['encoder.pth','atomic_preprocessor.pth','geometric_preprocessor.pth','cosmo_head.pth',
'training_state.pt','metrics.json','manifest.json','label_transform.json','post_training_architecture_config.yaml','cosmo_decoder_config.json']
ops=[CommitOperationAdd(path_in_repo=f'epoch_{path.name.split("_")[-1]}/{n}',path_or_fileobj=str(path/n)) for n in names]
commit=api.create_commit(repo_id=cfg['model_repository'],operations=ops,
commit_message=f'Save complete COSMO epoch {path.name}: weights, optimizer, scheduler, RNG and transform')
entries={s.rfilename:s for s in api.model_info(cfg['model_repository'],revision=commit.oid,files_metadata=True).siblings}
for n in ['encoder.pth','atomic_preprocessor.pth','geometric_preprocessor.pth','cosmo_head.pth','training_state.pt']:
entry=entries[f'{path.name}/{n}']
remote=entry.lfs.sha256 if entry.lfs else None
assert remote==sha(path/n),(n,remote)
result=dict(epoch=int(path.name.split('_')[-1]),repository=cfg['model_repository'],revision=commit.oid,verified=True,time=time.time())
write(marker,result)
return result
def main():
parser=argparse.ArgumentParser();parser.add_argument('--smoke',action='store_true')
parser.add_argument('--verify-resume',action='store_true');args=parser.parse_args()
lock=(WORK/'training.lock').open('a')
try:fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:
print('TRAINING_ALREADY_RUNNING',flush=True);return
cfg=json.loads((WORK/'config.json').read_text())
api=HfApi()
status('loading_published_dataset')
reference_path=WORK/'dataset_reference.json'
reference=json.loads(reference_path.read_text()) if reference_path.exists() else {}
revision=reference.get('revision') or api.dataset_info(cfg['dataset_repository']).sha
data_root=Path(snapshot_download(cfg['dataset_repository'],repo_type='dataset',revision=revision,
allow_patterns=['data/*','manifest.json','label_transform.json','pm6_source.json']))
manifest=json.loads((data_root/'manifest.json').read_text())
assert manifest['rows']==361583
# Check every downloaded training shard against the independently assembled manifest.
for name,digest in manifest['files'].items():assert sha(data_root/name)==digest,name
transform=json.loads((data_root/'label_transform.json').read_text())
frames=[]
for split in ['train','validation']:
frame=pd.concat([pd.read_parquet(p) for p in sorted((data_root/'data').glob(split+'-*.parquet'))],ignore_index=True)
assert len(frame)==manifest['splits'][split]
frame['is_validation']=split=='validation';frames.append(frame)
df=pd.concat(frames,ignore_index=True)
tr=np.flatnonzero(~df.is_validation.values);va=np.flatnonzero(df.is_validation.values)
assert not set(df.molecule_group.iloc[tr])&set(df.molecule_group.iloc[va])
target_cols=[f'p_{i:03d}' for i in range(153)]+['area_A2','volume_A3']
labels=df[target_cols].to_numpy(np.float64)
transformed=np.concatenate([np.log1p(labels[:,:153]),np.log(labels[:,153:])],axis=1)
mean=np.asarray(transform['mean']);scale=np.asarray(transform['scale'])
assert np.allclose(transformed[tr].mean(0),mean,atol=1e-10)
assert np.allclose(np.maximum(transformed[tr].std(0),1e-3),scale,atol=1e-10)
targets=torch.tensor((transformed-mean)/scale,dtype=torch.float32,device='cuda')
mean=torch.tensor(mean,dtype=torch.float32,device='cuda');scale=torch.tensor(scale,dtype=torch.float32,device='cuda')
positions=[torch.tensor(np.stack(x),dtype=torch.float32) for x in df.positions]
numbers=[torch.tensor(np.asarray(x),dtype=torch.long) for x in df.atomic_numbers]
lengths=np.array([len(z) for z in numbers])
assert np.isfinite(labels).all()
write(WORK/'dataset_reference.json',dict(repository=cfg['dataset_repository'],revision=revision,manifest=manifest))
completed=sorted((WORK/'checkpoints').glob('epoch_[0-9][0-9]')) if (WORK/'checkpoints').exists() else []
if completed and not args.smoke:
init=completed[-1]
else:
init=WORK/'initial_epoch_02';init.mkdir(exist_ok=True)
for name in ['encoder.pth','atomic_preprocessor.pth','geometric_preprocessor.pth','manifest.json']:
src=Path(hf_hub_download(cfg['initial_checkpoint_repository'],
filename=cfg['initial_checkpoint_path']+'/'+name,revision=cfg['initial_checkpoint_revision']))
if not (init/name).exists():shutil.copyfile(src,init/name)
initial_manifest=json.loads((init/'manifest.json').read_text())
for name in ['encoder.pth','atomic_preprocessor.pth','geometric_preprocessor.pth']:
assert sha(init/name)==initial_manifest['files'][name]['sha256']
torch.manual_seed(cfg['seed']);torch.cuda.manual_seed_all(cfg['seed']);np.random.seed(cfg['seed']);random.seed(cfg['seed'])
pre,enc=backbone(init)
model=COSMORegressor(pre,enc)
optimizer=torch.optim.AdamW([
{'params':list(pre.parameters())+list(enc.parameters()),'lr':cfg['learning_rate_backbone']},
{'params':model.head.parameters(),'lr':cfg['learning_rate_head']}],weight_decay=cfg['weight_decay'])
# Epoch schedule has a stable horizon even when atom-budget packing varies.
scheduler=torch.optim.lr_scheduler.LambdaLR(optimizer,lambda e:.1+.9*.5*(1+math.cos(math.pi*e/cfg['epochs'])))
mace=build_author_mace_runtime().cuda().eval().requires_grad_(False)
captures=[]
hook=mace.preprocessor.atomic_preprocessor.register_forward_pre_hook(lambda m,a:captures.append(a[0].detach()))
frozen_buffers={n:b.detach().cpu().clone() for n,b in pre.atomic_preprocessor.invariant_normalization.named_buffers()}
def sample(ids):
batch=yield_molecules_collate_fn([Sample(atomic_positions=positions[i],atomic_numbers=numbers[i],total_charge=0.,multiplicity=1.) for i in ids]).to_('cuda')
captures.clear()
with torch.no_grad():mace.preprocessor(batch)
assert len(captures)==1
raw=captures.pop().clone()
return LegacySample(embeddings=raw,atomic_positions=batch.atomic_positions,padding_mask=batch.padding_mask)
def update(ids):
optimizer.zero_grad(set_to_none=True)
pred=model(sample(ids));loss,parts=loss_terms(pred,targets[ids])
assert torch.isfinite(loss),'Nonfinite COSMO loss'
loss.backward()
norm=nn.utils.clip_grad_norm_(model.parameters(),1.,error_if_nonfinite=True)
optimizer.step()
return float(loss.detach()),parts,float(norm)
if args.smoke:
ids=tr[:8].tolist();before=next(enc.parameters()).detach().clone()
model.train();result=update(ids)
assert not torch.equal(before,next(enc.parameters()).detach()),'Encoder did not update'
assert any(p.grad is not None and bool((p.grad!=0).any()) for p in pre.parameters())
assert all(p.grad is None for p in mace.parameters())
model.eval()
with torch.no_grad():
s=sample(ids);ref=model(s);physical=inverse(targets[ids],mean,scale)
assert np.allclose(physical.cpu().numpy(),labels[ids],rtol=1e-5,atol=1e-4)
state={k:v.cpu() for k,v in model.state_dict().items()}
path=WORK/'smoke_model.pt';torch.save(state,path)
model.load_state_dict(torch.load(path,map_location='cpu',weights_only=True))
with torch.no_grad():assert torch.allclose(ref,model(s),atol=1e-6,rtol=1e-6)
for n,b in pre.atomic_preprocessor.invariant_normalization.named_buffers():assert torch.equal(b.cpu(),frozen_buffers[n])
write(WORK/'smoke_validation.json',dict(passed=True,loss=result[0],gradient_norm=result[2],
decoder_outputs=155,encoder_updated=True,preprocessor_gradients=True,mace_frozen=True,
transform_roundtrip=True,checkpoint_roundtrip=True,dataset_revision=revision))
path.unlink();status('smoke_passed');return
smoke=json.loads((WORK/'smoke_validation.json').read_text())
assert smoke['passed'] and smoke['dataset_revision']==revision,'Smoke validation must match the pinned dataset'
import wandb
api.create_repo(cfg['model_repository'],repo_type='model',exist_ok=True)
wb=json.loads((WORK/'wandb.json').read_text()) if (WORK/'wandb.json').exists() else {'run_id':wandb.util.generate_id()}
write(WORK/'wandb.json',wb)
run=None
if not args.verify_resume:
run=wandb.init(entity=cfg['wandb_entity'],project=cfg['wandb_project'],id=wb['run_id'],resume='allow',mode='online',
name='rem3di-pcqm-epoch2-cosmo-10epochs',group='rem3di-cosmo',job_type='cosmo-posttraining',
config={**cfg,'dataset_revision':revision,'loss':'(mean sigma z-score MSE + A z-score MSE + V z-score MSE)/3',
'train_rows':len(tr),'validation_rows':len(va),'mace':'frozen; original PM6 coordinates; no geometry augmentation'},
dir=str(WORK),settings=wandb.Settings(init_timeout=90))
write(WORK/'wandb.json',dict(run_id=run.id,url=run.url))
run.define_metric('global_step');run.define_metric('train/*',step_metric='global_step')
run.define_metric('epoch');run.define_metric('validation/*',step_metric='epoch')
start_epoch=1;global_step=0
if completed:
last=completed[-1]
saved_manifest=json.loads((last/'manifest.json').read_text())
for name in ['encoder.pth','atomic_preprocessor.pth','geometric_preprocessor.pth','cosmo_head.pth','training_state.pt']:
assert sha(last/name)==saved_manifest['files'][name]['sha256'],name
for n,module in [('encoder.pth',enc),('atomic_preprocessor.pth',pre.atomic_preprocessor),('geometric_preprocessor.pth',pre.geometric_preprocessor),('cosmo_head.pth',model.head)]:
module.load_state_dict(torch.load(last/n,map_location='cpu',weights_only=True))
state=torch.load(last/'training_state.pt',map_location='cpu',weights_only=False)
assert state['dataset_revision']==revision,'Resume must preserve the original dataset revision'
assert state['config']==cfg,'Resume configuration differs from the saved run'
assert state['epoch']==int(last.name.split('_')[-1])
optimizer.load_state_dict(state['optimizer']);scheduler.load_state_dict(state['scheduler'])
torch.set_rng_state(state['torch_rng']);torch.cuda.set_rng_state_all(state['cuda_rng'])
np.random.set_state(state['numpy_rng']);random.setstate(state['python_rng'])
start_epoch=state['epoch']+1;global_step=state['global_step']
upload_epoch(api,cfg,last)
write(WORK/'resume_state.json',dict(restored_epoch=state['epoch'],next_epoch=start_epoch,
global_step=global_step,dataset_revision=revision,optimizer_restored=True,scheduler_restored=True,rng_restored=True))
print('RESUMED',json.dumps(dict(epoch=state['epoch'],next_epoch=start_epoch,global_step=global_step)),flush=True)
if args.verify_resume:
assert completed,'No completed checkpoint to verify'
model.eval()
with torch.no_grad():
output=model(sample(tr[:8].tolist()))
loss,_=loss_terms(output,targets[tr[:8]])
physical=inverse(output,mean,scale)
assert output.shape==(8,155) and torch.isfinite(output).all() and torch.isfinite(physical).all()
assert torch.isfinite(loss) and optimizer.state and scheduler.last_epoch==start_epoch-1
write(WORK/'resume_validation.json',dict(passed=True,restored_epoch=start_epoch-1,next_epoch=start_epoch,
global_step=global_step,loss=float(loss),optimizer_state_tensors=len(optimizer.state),
scheduler_epoch=scheduler.last_epoch,dataset_revision=revision,finite_predictions=True))
hook.remove();status('resume_verified',next_epoch=start_epoch,global_step=global_step);return
try:
for epoch in range(start_epoch,cfg['epochs']+1):
model.train();started=time.time();loss_sum=0.;count=0
rng=np.random.default_rng(cfg['seed']+epoch)
plan=list(batches(tr,lengths,rng,max_size=cfg['max_molecules_per_batch'],atom_cap=cfg['max_atoms_per_batch']))
status('training',epoch=epoch,epochs=cfg['epochs'],batches=len(plan),wandb_url=run.url)
for bi,ids in enumerate(plan,1):
loss,parts,norm=update(ids)
global_step+=1;loss_sum+=loss*len(ids);count+=len(ids)
if bi==1 or bi%25==0:
row=dict(global_step=global_step,epoch=epoch,batch=bi,batches=len(plan),
**{'train/'+k:v for k,v in dict(loss=loss,gradient_norm=norm,backbone_lr=optimizer.param_groups[0]['lr'],head_lr=optimizer.param_groups[1]['lr'],**parts).items()})
run.log(row)
status('training',**row,wandb_url=run.url,molecules_seen=count,seconds=time.time()-started)
model.eval();val_sum=0.;val_count=0;sse=np.zeros(3);mae=np.zeros(3);sumarea=0.
with torch.no_grad():
for ids in batches(va,lengths,max_size=cfg['max_molecules_per_batch'],atom_cap=cfg['max_atoms_per_batch']):
pred=model(sample(ids));loss,_=loss_terms(pred,targets[ids])
val_sum+=float(loss)*len(ids);val_count+=len(ids)
physical=inverse(pred,mean,scale).cpu().numpy()
assert np.isfinite(physical).all()
err=physical-labels[ids]
sse += [float((err[:,:153]**2).sum()/153),float((err[:,153]**2).sum()),float((err[:,154]**2).sum())]
mae += [float(abs(err[:,:153]).sum()/153),float(abs(err[:,153]).sum()),float(abs(err[:,154]).sum())]
sumarea+=float(abs(physical[:,:153].sum(1)-physical[:,153]).sum())
assert count==len(tr) and val_count==len(va)
for n,b in pre.atomic_preprocessor.invariant_normalization.named_buffers():assert torch.equal(b.cpu(),frozen_buffers[n])
scheduler.step()
metrics=dict(epoch=epoch,global_step=global_step,train_loss=loss_sum/count,validation_loss=val_sum/val_count,
validation_sigma_rmse=float(np.sqrt(sse[0]/val_count)),validation_area_rmse=float(np.sqrt(sse[1]/val_count)),
validation_volume_rmse=float(np.sqrt(sse[2]/val_count)),validation_sigma_mae=float(mae[0]/val_count),
validation_area_mae=float(mae[1]/val_count),validation_volume_mae=float(mae[2]/val_count),
validation_profile_area_inconsistency=float(sumarea/val_count),seconds=time.time()-started)
assert all(math.isfinite(v) for v in metrics.values())
target=WORK/'checkpoints'/f'epoch_{epoch:02d}'
pending=target.with_name(target.name+'.partial');pending.mkdir(parents=True,exist_ok=True)
for n,module in [('encoder.pth',enc),('atomic_preprocessor.pth',pre.atomic_preprocessor),('geometric_preprocessor.pth',pre.geometric_preprocessor),('cosmo_head.pth',model.head)]:
torch.save(module.state_dict(),pending/n)
torch.save(dict(epoch=epoch,global_step=global_step,optimizer=optimizer.state_dict(),scheduler=scheduler.state_dict(),
torch_rng=torch.get_rng_state(),cuda_rng=torch.cuda.get_rng_state_all(),numpy_rng=np.random.get_state(),
python_rng=random.getstate(),dataset_revision=revision,config=cfg),pending/'training_state.pt')
write(pending/'metrics.json',metrics)
shutil.copyfile(data_root/'label_transform.json',pending/'label_transform.json')
architecture=yaml.safe_load((ORIGINAL/'post_training_architecture_config.yaml').read_text())
architecture.pop('decoder_config',None);architecture['kind']='encoder_only'
architecture['mace_config']['model_path']='MACE-POLAR-1-M.model'
(pending/'post_training_architecture_config.yaml').write_text(yaml.safe_dump(architecture,sort_keys=False))
write(pending/'cosmo_decoder_config.json',dict(input_features=640,hidden_features=256,output_features=155,activation='SiLU'))
write(pending/'manifest.json',dict(epoch=epoch,dataset_revision=revision,config=cfg,
files={p.name:dict(bytes=p.stat().st_size,sha256=sha(p)) for p in pending.iterdir() if p.is_file()}))
pending.rename(target)
status('uploading_checkpoint',epoch=epoch,metrics=metrics,wandb_url=run.url)
for attempt in range(5):
try:
result=upload_epoch(api,cfg,target);break
except Exception:
if attempt==4:raise
print('UPLOAD_RETRY',traceback.format_exc(),flush=True);time.sleep(min(10*2**attempt,60))
run.log(dict(epoch=epoch,**{'validation/'+k.removeprefix('validation_'):v for k,v in metrics.items() if k.startswith('validation_')},
**{'epoch/'+k:v for k,v in metrics.items()},**{'backup/revision':result['revision'],'backup/verified_epoch':epoch}))
write(WORK/'latest_epoch.json',dict(**metrics,hub=result,wandb_url=run.url))
write(WORK/'training_complete.json',dict(epochs=cfg['epochs'],wandb_url=run.url,completed_unix=time.time()))
run.summary['training_complete']=True;run.finish();status('complete',epochs=cfg['epochs'])
except Exception:
status('failed',error=traceback.format_exc());run.finish(exit_code=1);raise
finally:
hook.remove()
if __name__=='__main__':
try:main()
except Exception:
status('failed',error=traceback.format_exc());raise