diff --git a/geom/scaffold_murcko_substituent_lora_r16/README.md b/geom/scaffold_murcko_substituent_lora_r16/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7b2b7b170d1100c98dc09c2e2d3aa8dab702f5d9 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/README.md @@ -0,0 +1,40 @@ +# GEOM scaffold decoration: 25 completed fine-tuning epochs + +This is the original checkpoint from the latest GEOM Murcko scaffold fine-tuning run, +with rank-16 LoRA, trainable output heads, and substituent-constrained insertion +supervision. It starts from GEOM explicit-H epoch 100 EMA weights. The stored +Lightning epoch is 24; 25 fine-tuning epochs have completed (199,100 steps). + +This conditional checkpoint requires a scaffold. Use its saved architecture and +configuration; the unconditional `morph generate` interface/catalog does not load it. +Both current and EMA networks and optimizer state are retained. EMA is used for evaluation. +Scaffold atom identities, internal bonds and charges are preserved; positions can move. +Substituent assignment affects training supervision and does not require hidden target +bonds at generation time. + +## Files and reproduction + +- `epoch_025.ckpt`: unmodified original checkpoint; `metadata.json` records its SHA-256. +- `training_config.yaml`: original composed training configuration, including provenance paths. +- `config.yaml`: equivalent configuration with portable asset/data paths and base loading disabled. +- Ordered atom/edge/charge vocabularies and `distributions.pt`: exact model assets. +- `panels/`: fixed 512-scaffold validation and test selections, with eight attempts per scaffold. +- `training_source.tar.gz`: original frozen training source, training manifest, and insertion-assignment audit. +- `runtime/`: evaluation source snapshot, including corrected raw-graph RDKit stability. +- `evaluate.py`: evaluation/preflight/aggregation driver; `manifest.json` hashes every release file. + +In an existing compatible Morph environment, set `PYTHONPATH` to `runtime/src:runtime` +and run `evaluate.py preflight --bundle --data-root +--output `. The GEOM root must contain the matching processed data and +scaffold caches. The script sets portable asset environment variables itself. + +The cluster evaluation uses the held-out **test panel, 512 × 8 attempts × seeds 42/43/44**, +100 integration steps, EMA weights, and no inference scaffold jitter. It reports native +scaffold metrics, strict and scaffold-aware PoseBusters, raw-graph stability, chemistry, +and distribution/geometry diagnostics. Native PoseBusters omits the separate full offline +energy/flatness checks. All scalar metrics are aggregated into one `summary.csv`. + +The evaluation driver requires a completed upload-verification JSON before generation; +this is a provenance guard for this release. Generated coordinates are not relaxed by this +scaffold evaluation. The checkpoint/configuration hashes and original training archive +allow the training and evaluation implementations to be distinguished. diff --git a/geom/scaffold_murcko_substituent_lora_r16/atom_tokens.txt b/geom/scaffold_murcko_substituent_lora_r16/atom_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..d26c72a1280cb2fc09f0caa954cd4a5cc9e74b36 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/atom_tokens.txt @@ -0,0 +1,10 @@ +Br +C +Cl +F +H +I +N +O +P +S diff --git a/geom/scaffold_murcko_substituent_lora_r16/charge_tokens.txt b/geom/scaffold_murcko_substituent_lora_r16/charge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..3ca179801a8bfa91f0e7d8861b931916836a370b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/charge_tokens.txt @@ -0,0 +1,5 @@ +-2 +-1 +0 +1 +2 diff --git a/geom/scaffold_murcko_substituent_lora_r16/config.yaml b/geom/scaffold_murcko_substituent_lora_r16/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7f7ca4ab252a585f283517aebef7f3fa121ed0fe --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/config.yaml @@ -0,0 +1,461 @@ +representation: molecule +seed: 42 +heads: + heads: + _target_: morph.model.layers.heads.MultiHeadModule + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + heads_configs: + graph_aggregation: mean + node_heads: + atom_type_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.atom_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + charge_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.charge_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + ins_rate_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_rate: ${model.head_initialization.ins_rate} + do_del_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_del_probability} + do_sub_a_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_sub_a_probability} + edge_heads: + edge_type_head: + input_dim: ${model.head_edge_dim} + output_dim: ${len:${data.vocab.edge_tokens}} + adapter_dim: ${model.head_edge_adapter_dim} + group: state + do_sub_e_head: + input_dim: ${model.head_edge_dim} + output_dim: 1 + adapter_dim: ${model.head_edge_adapter_dim} + group: state + initial_probability: ${model.head_initialization.do_sub_e_probability} + ins_edge_head: + _target_: morph.model.layers.heads.InsertionEdgeHead + _recursive_: false + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_edge_dim} + output_init_std: ${model.head_initialization.output_std} + n_edge_types: ${len:${data.vocab.edge_tokens}} + n_atom_types: ${len:${data.vocab.atom_tokens}} + n_charge_types: ${len:${data.vocab.charge_tokens}} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +insertion_head: + mode: unimodal + dimension: 3 + num_components: 1 + model: + _target_: morph.model.layers.heads.UnimodalInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} +model: + module: + optimizer_config: + implementation: torch + optimizer: + execution: ${oc.env:MORPH_MUON_EXECUTION,native} + lr: 0.001 + momentum: 0.95 + weight_decay: 0.0 + nesterov: true + ns_steps: 5 + eps: 1.0e-07 + adjust_lr_fn: original + aux_optimizer: + lr: 0.0001 + betas: + - 0.9 + - 0.95 + eps: 1.0e-10 + weight_decay: 0.0 + gradient_clip_val: 1.0 + gradient_clip_algorithm: norm + scheduler: + _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR + warmup_steps: 2000 + total_steps: 200000 + min_lr_fraction: 0.05 + last_epoch: -1 + interval: step + monitor: null + loss_weights: + do_sub_a: 0.2 + sub_a_class: 0.03 + do_sub_e: 0.25 + sub_e_class: 0.5 + do_del: 0.4 + ins_rate: 0.15 + ins_state: 0.01 + ins_e: 0.4 + x: 2.0 + c: 0.5 + ins_e_ii: 0.03 + _target_: morph.model.finetune.scaffold.LightningModuleRatesScaffoldFinetune + insertion_head_mode: ${insertion_head.mode} + ins_noise_scale: ${data.ins_noise_scale} + ema_decay: 0.999 + ema_warmup_steps: 5000 + use_ema_for_eval: true + time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched}, + ${model.linear_sched}} + integrator: ${model.integrator} + vocab: ${data.vocab} + use_learnable_loss_weights: false + use_time_weights: false + model: + _target_: morph.model.finetune.scaffold.ScaffoldConditionalDiTBackboneWithHeads + _recursive_: false + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + incident_bond_embedding: false + backbone_model_args: ${model.backbone} + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + xattn_encoder_args: ${model.xattn_encoder} + use_distance_bias: true + pretrained_ckpt: null + pretrained_prefer_ema: true + freeze_pretrained_backbone: true + freeze_output_heads: false + exclude_scaffold_from_sub_loss: true + exclude_scaffold_from_x_loss: false + freeze_keep_positions: false + freeze_keep_charges: true + rotate: true + n_augmentations: 1 + integrator: ${integrator.integrator} + beta_sched: + _target_: torch.distributions.Beta + concentration1: 2.0 + concentration0: 1.0 + linear_sched: + _target_: torch.distributions.Uniform + low: 0.0 + high: 1.0 + sample_time_strategy: linear + head_node_dim: ${model.backbone.out_node_nf} + head_edge_dim: ${model.backbone.out_edge_nf} + head_node_adapter_dim: 256 + head_edge_adapter_dim: 256 + head_insertion_adapter_dim: 256 + head_insertion_edge_dim: 256 + head_independent_adapters: true + head_initialization: + output_std: 0.001 + coordinate_output_std: 0.0 + do_del_probability: 0.5 + do_sub_a_probability: 0.5 + do_sub_e_probability: 0.5 + ins_rate: 0.6931471805599453 + token_weighting: + type_loss_token_weights: uniform + weight_alpha: 0.4 + lora: + enabled: true + rank: 16 + alpha: 16.0 + dropout: 0.0 + targets: + - qkv + - proj + - mlp.gate_value + - mlp.out_proj + freeze_base: true + backbone: + _target_: morph.model.backbones.conditional_dit.ConditionalDiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false + ctx_dim: 128 + use_cross_attn: true + use_node_cond: true + use_edge_cond: true + xattn_encoder: + _target_: morph.model.finetune.base.LightweightContextEncoder + num_atom_types: ${len:${data.vocab.atom_tokens}} + num_charges: ${len:${data.vocab.charge_tokens}} + atom_embed_dim: 64 + charge_embed_dim: 16 + pos_embed_dim: 32 + ctx_dim: ${model.backbone.ctx_dim} + num_attn_layers: 4 + nhead: 8 + mlp_ratio: 4.0 + dropout: 0.0 + use_pair_bias: true + num_edge_types: ${len:${data.vocab.edge_tokens}} + edge_embed_dim: 32 + num_rbf: 32 + rbf_dmax: 5.0 + pair_bias_hidden_dim: 32 +schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 0.8 +trainer: + trainer: + deterministic: warn + devices: 2 + accelerator: gpu + strategy: ddp_find_unused_parameters_false + precision: bf16-mixed + max_epochs: 50 + accumulate_grad_batches: 1 + num_sanity_val_steps: 0 + gradient_clip_val: null + gradient_clip_algorithm: norm + check_val_every_n_epoch: 1 + num_val_samples: 4096 + limit_val_batches: null + limit_predict_batches: 4 + enable_progress_bar: false + max_time: + hours: 0 + seconds: 169927 + do_train: true + do_validate: false + do_predict: false + checkpoint_path: null + final_checkpoint_path: final.ckpt +callbacks: {} +logging: + enabled: false + project: morph-geom-scaffold-decoration + name: ${oc.env:MORPH_RUN_ID,base} + save_dir: . + offline: false + group: geom-e100-murcko-original-50ep-20261010 + tags: + - substituent-insertion + - rank-16 + - scaffold-decoration + - bemis-murcko + - geom-e100 + - lora + - trainable-heads + - movable-scaffold + - jitter-0.1A + - scaffold-metrics +integrator: + integrator: + _target_: morph.flow_matching.integration.RateIntegrator + vocab: ${data.vocab} + distributions: None + insertion_head_mode: ${insertion_head.mode} + insertion_dimension: ${insertion_head.dimension} + insertion_num_components: ${insertion_head.num_components} + time_strategy: log + time_beta_alpha: 2.0 + time_beta_beta: 1.0 + num_integration_steps: 100 + ins_noise_scale: ${data.ins_noise_scale} + renoise_insertions: true + exact_schedule_integration: true + del_schedule: ${data.interpolator.del_schedule} + ins_schedule: ${data.interpolator.ins_schedule} + sub_schedule: ${data.interpolator.sub_schedule} + sub_e_schedule: ${data.interpolator.sub_e_schedule} +data: + root_path: ${oc.env:PROJECT_ROOT}/data + preprocess_workers: 10 + preprocess_chunk_size: 128 + statistics_workers: ${data.preprocess_workers} + hydrogen_mode: explicit + cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} + source_dir: ${oc.env:GEOM_SOURCE_DIR,null} + max_atoms: 72 + allowed_atom_symbols: + - H + - C + - 'N' + - O + - S + - F + - Cl + - Br + - P + - I + n_atoms_strategy: uniform + uniform_n_atoms_padding: 5 + optimal_transport: equivariant + ins_noise_scale: 0.65 + move_noise_scale: 0.5 + allow_charged: true + preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: ${oc.env:MORPH_GEOM_ROOT} + num_workers: ${data.statistics_workers} + train_dataset: ${data.datamodule.datasets.train} + atom_tokens_path: ${oc.env:MORPH_SCAFFOLD_BUNDLE}/atom_tokens.txt + edge_tokens_path: ${oc.env:MORPH_SCAFFOLD_BUNDLE}/edge_tokens.txt + charge_tokens_path: ${oc.env:MORPH_SCAFFOLD_BUNDLE}/charge_tokens.txt + distributions_path: ${oc.env:MORPH_SCAFFOLD_BUNDLE}/distributions.pt + vocab: null + interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: null + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + atom_type_noise_prob: 0.0 + pre_align_iters: 2 + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + jitter_scale: ${data.jitter_scale} + insertion_assignment: ${data.insertion_assignment} + datamodule: + _target_: morph.dataset.conditional.scaffold.datamodule.ScaffoldLightningDataModule + vocab: ${data.vocab} + distributions: null + representation: ${representation} + datasets: + train: + _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + val: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + test: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + interpolator: ${data.interpolator} + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + num_workers: + train: 20 + val: 0 + test: 0 + batch_size: + train: 320 + val: 32 + test: 32 + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} + scaffold_groups_dir: ${data.scaffold_groups_dir} + scaffold_kind: ${data.scaffold_kind} + enumerate_slicings: ${data.enumerate_slicings} + eval_panel: ${data.eval_panel} + train_prefetch_factor: 1 + dataset_name: geom + dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + scaffold_groups_dir: ${data.preprocessing.root}/processed/scaffold_groups + scaffold_kind: murcko + jitter_scale: 0.1 + insertion_assignment: substituent + enumerate_slicings: false + eval_panel: + directory: ${oc.env:MORPH_SCAFFOLD_BUNDLE}/panels + n_scaffolds: 512 + samples_per_scaffold: 8 + seed: 42 +embeddings: + atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 128.0 + max_period: 1024.0 + max_count: 96.0 diff --git a/geom/scaffold_murcko_substituent_lora_r16/distributions.json b/geom/scaffold_murcko_substituent_lora_r16/distributions.json new file mode 100644 index 0000000000000000000000000000000000000000..5257e64dcd17b88651740050603ac130f0124f37 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/distributions.json @@ -0,0 +1,15406 @@ +{ + "atom_type_distribution": [ + 0.0009399419068358839, + 0.40246495604515076, + 0.003820041660219431, + 0.004622052423655987, + 0.4455429017543793, + 1.746328780427575e-05, + 0.06322308629751205, + 0.06675907224416733, + 7.518968777731061e-05, + 0.012535374611616135 + ], + "edge_type_distribution": [ + 0.9558339715003967, + 0.036910656839609146, + 0.007218532729893923, + 3.680539157357998e-05 + ], + "charge_type_distribution": [ + 1.2930099728691857e-06, + 0.0020575423259288073, + 0.9950409531593323, + 0.00290004163980484, + 1.6449536133222864e-07 + ], + "n_atoms_distribution": [ + 0.0, + 0.0, + 0.0, + 1.7712740429942642e-07, + 0.0, + 3.5425480859885283e-07, + 3.5425480859885283e-07, + 3.5425480859885283e-07, + 1.239891730620002e-06, + 2.8340384687908227e-06, + 8.502114724251442e-06, + 4.782439646078274e-06, + 1.558721123728901e-05, + 3.648824349511415e-05, + 4.9772799684433267e-05, + 6.0046186263207346e-05, + 7.350787200266495e-05, + 0.00011017324140993878, + 0.00019696567323990166, + 0.000267993746092543, + 0.0003774584911298007, + 0.0005579512799158692, + 0.0009449746576137841, + 0.0012839965056627989, + 0.002041747560724616, + 0.002648585941642523, + 0.0038034566678106785, + 0.004636486526578665, + 0.006434684153646231, + 0.008230401203036308, + 0.01023300364613533, + 0.012141905725002289, + 0.014930599369108677, + 0.017782174050807953, + 0.020237158983945847, + 0.023916803300380707, + 0.026766251772642136, + 0.028156703338027, + 0.031546566635370255, + 0.03363773226737976, + 0.034495383501052856, + 0.037021927535533905, + 0.03860349953174591, + 0.03938250616192818, + 0.040041595697402954, + 0.03986889868974686, + 0.04040771722793579, + 0.039737291634082794, + 0.03794635832309723, + 0.0365116260945797, + 0.03570268303155899, + 0.033752866089344025, + 0.03189922869205475, + 0.030490001663565636, + 0.02852034568786621, + 0.02656574361026287, + 0.02402130886912346, + 0.022764058783650398, + 0.020507987588644028, + 0.01766757294535637, + 0.01609361730515957, + 0.014043014496564865, + 0.012413442134857178, + 0.010036392137408257, + 0.009312295354902744, + 0.007565110921859741, + 0.005838650278747082, + 0.005072042811661959, + 0.004129016771912575, + 0.00307723437435925, + 0.0029760946054011583, + 0.002396710915490985 + ], + "coordinate_std": 2.4406259059906006, + "pairwise_distance_histogram": [ + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 906.0, + 140.0, + 75.0, + 105.0, + 303.0, + 184.0, + 22.0, + 45.0, + 41.0, + 53.0, + 57.0, + 296.0, + 335.0, + 44.0, + 41.0, + 86.0, + 42.0, + 39.0, + 28.0, + 36.0, + 36.0, + 28.0, + 21.0, + 42.0, + 139.0, + 3804.0, + 353.0, + 47.0, + 36.0, + 54.0, + 45.0, + 31.0, + 47.0, + 73.0, + 334.0, + 38.0, + 41.0, + 23.0, + 37.0, + 31.0, + 18.0, + 46.0, + 59.0, + 47.0, + 30.0, + 42.0, + 54.0, + 41.0, + 35.0, + 29.0, + 33.0, + 33.0, + 36.0, + 34.0, + 44.0, + 219.0, + 42.0, + 35.0, + 27.0, + 34.0, + 45.0, + 43.0, + 31.0, + 53.0, + 47.0, + 20.0, + 28.0, + 27.0, + 23.0, + 21.0, + 18.0, + 35.0, + 28.0, + 30.0, + 42.0, + 45.0, + 30.0, + 42.0, + 30.0, + 29.0, + 29.0, + 27.0, + 16.0, + 12.0, + 19.0, + 14.0, + 24.0, + 26.0, + 26.0, + 33.0, + 32.0, + 26.0, + 21.0, + 21.0, + 26.0, + 22.0, + 69.0, + 69.0, + 37.0, + 17.0, + 31.0, + 23.0, + 13.0, + 15.0, + 33.0, + 74.0, + 12.0, + 5.0, + 12.0, + 5.0, + 18.0, + 7.0, + 6.0, + 7.0, + 8.0, + 4.0, + 8.0, + 4.0, + 91.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12.0, + 110843.0, + 134616.0, + 235.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2767.0, + 422354.0, + 40592.0, + 1068.0, + 18057.0, + 15937.0, + 7693.0, + 15022.0, + 14893.0, + 17421.0, + 19252.0, + 23093.0, + 38199.0, + 40716.0, + 432083.0, + 46430.0, + 31398.0, + 25887.0, + 34241.0, + 223385.0, + 32154.0, + 29838.0, + 29400.0, + 29183.0, + 37249.0, + 46730.0, + 97936.0, + 44001.0, + 34330.0, + 38614.0, + 32939.0, + 31224.0, + 33046.0, + 35343.0, + 72459.0, + 38345.0, + 38358.0, + 47895.0, + 44644.0, + 39619.0, + 46167.0, + 65960.0, + 68309.0, + 66383.0, + 44820.0, + 38541.0, + 36679.0, + 37605.0, + 39037.0, + 40929.0, + 41339.0, + 34860.0, + 32172.0, + 32587.0, + 37171.0, + 44442.0, + 47044.0, + 38892.0, + 29566.0, + 32095.0, + 36072.0, + 35539.0, + 32840.0, + 31700.0, + 30267.0, + 29798.0, + 27471.0, + 27302.0, + 25136.0, + 22399.0, + 21465.0, + 21025.0, + 21592.0, + 21798.0, + 21971.0, + 21168.0, + 21686.0, + 20324.0, + 20433.0, + 17161.0, + 16920.0, + 15543.0, + 16413.0, + 15408.0, + 13291.0, + 12757.0, + 11594.0, + 11825.0, + 10764.0, + 10095.0, + 9327.0, + 8980.0, + 8035.0, + 7749.0, + 7495.0, + 7305.0, + 7162.0, + 7120.0, + 6467.0, + 6501.0, + 5796.0, + 5467.0, + 4996.0, + 4785.0, + 4325.0, + 4163.0, + 3993.0, + 3664.0, + 3218.0, + 3025.0, + 2943.0, + 2798.0, + 2664.0, + 2416.0, + 2270.0, + 2063.0, + 2010.0, + 1951.0, + 1814.0, + 1858.0, + 1711.0, + 1485.0, + 16933.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 76.0, + 1.0, + 644.0, + 56.0, + 29.0, + 54.0, + 101.0, + 114.0, + 114.0, + 108.0, + 87.0, + 92.0, + 90.0, + 94.0, + 102.0, + 101.0, + 115.0, + 121.0, + 122.0, + 134.0, + 174.0, + 205.0, + 164.0, + 121.0, + 640.0, + 3859.0, + 132.0, + 105.0, + 135.0, + 145.0, + 132.0, + 156.0, + 145.0, + 1310.0, + 132.0, + 143.0, + 141.0, + 133.0, + 152.0, + 147.0, + 152.0, + 167.0, + 158.0, + 182.0, + 128.0, + 141.0, + 115.0, + 136.0, + 161.0, + 126.0, + 127.0, + 174.0, + 151.0, + 177.0, + 158.0, + 166.0, + 147.0, + 166.0, + 173.0, + 163.0, + 148.0, + 133.0, + 125.0, + 136.0, + 117.0, + 142.0, + 153.0, + 106.0, + 118.0, + 121.0, + 117.0, + 111.0, + 111.0, + 140.0, + 145.0, + 114.0, + 116.0, + 108.0, + 107.0, + 129.0, + 134.0, + 97.0, + 106.0, + 95.0, + 79.0, + 74.0, + 115.0, + 129.0, + 105.0, + 105.0, + 81.0, + 82.0, + 65.0, + 65.0, + 65.0, + 51.0, + 81.0, + 67.0, + 55.0, + 58.0, + 67.0, + 57.0, + 44.0, + 32.0, + 72.0, + 37.0, + 41.0, + 35.0, + 32.0, + 18.0, + 27.0, + 19.0, + 22.0, + 30.0, + 15.0, + 25.0, + 76.0, + 14.0, + 13.0, + 186.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 60.0, + 0.0, + 4.0, + 29.0, + 295.0, + 255.0, + 44.0, + 108.0, + 185.0, + 226.0, + 135.0, + 140.0, + 154.0, + 160.0, + 163.0, + 170.0, + 188.0, + 179.0, + 179.0, + 291.0, + 185.0, + 193.0, + 328.0, + 166.0, + 199.0, + 3509.0, + 1139.0, + 271.0, + 211.0, + 227.0, + 276.0, + 204.0, + 197.0, + 684.0, + 249.0, + 225.0, + 253.0, + 298.0, + 269.0, + 218.0, + 371.0, + 738.0, + 278.0, + 318.0, + 323.0, + 270.0, + 295.0, + 178.0, + 216.0, + 542.0, + 214.0, + 208.0, + 197.0, + 203.0, + 258.0, + 225.0, + 218.0, + 260.0, + 354.0, + 225.0, + 194.0, + 194.0, + 196.0, + 228.0, + 327.0, + 287.0, + 213.0, + 197.0, + 229.0, + 217.0, + 202.0, + 174.0, + 144.0, + 212.0, + 155.0, + 172.0, + 237.0, + 278.0, + 225.0, + 176.0, + 196.0, + 183.0, + 135.0, + 150.0, + 162.0, + 140.0, + 141.0, + 133.0, + 141.0, + 168.0, + 148.0, + 129.0, + 145.0, + 122.0, + 125.0, + 110.0, + 153.0, + 159.0, + 93.0, + 117.0, + 87.0, + 77.0, + 85.0, + 79.0, + 72.0, + 76.0, + 81.0, + 66.0, + 73.0, + 79.0, + 59.0, + 76.0, + 66.0, + 44.0, + 46.0, + 55.0, + 41.0, + 49.0, + 39.0, + 49.0, + 33.0, + 35.0, + 32.0, + 445.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 285.0, + 8368.0, + 4096.0, + 6171.0, + 8991.0, + 19174.0, + 348632.0, + 34277.0, + 25774.0, + 32381.0, + 17040.0, + 14123.0, + 12788.0, + 12432.0, + 12884.0, + 18310.0, + 17212.0, + 16420.0, + 19532.0, + 30253.0, + 25385.0, + 24911.0, + 36162.0, + 32457.0, + 33026.0, + 49470.0, + 285564.0, + 82393.0, + 37689.0, + 46938.0, + 42277.0, + 37158.0, + 37573.0, + 50782.0, + 110008.0, + 40411.0, + 42324.0, + 49106.0, + 45644.0, + 52703.0, + 41894.0, + 54446.0, + 72117.0, + 76840.0, + 61863.0, + 65561.0, + 57610.0, + 51125.0, + 54868.0, + 48614.0, + 51135.0, + 50439.0, + 48490.0, + 47283.0, + 48153.0, + 66180.0, + 50298.0, + 47104.0, + 44890.0, + 45587.0, + 44184.0, + 47169.0, + 49142.0, + 49230.0, + 46402.0, + 44520.0, + 41832.0, + 42451.0, + 41745.0, + 35725.0, + 35191.0, + 33284.0, + 32677.0, + 31636.0, + 30965.0, + 31755.0, + 30618.0, + 30703.0, + 30336.0, + 29304.0, + 27350.0, + 25770.0, + 25317.0, + 25797.0, + 26079.0, + 25602.0, + 23779.0, + 21830.0, + 21247.0, + 20075.0, + 19600.0, + 18032.0, + 16648.0, + 15618.0, + 14495.0, + 13822.0, + 14264.0, + 14208.0, + 12882.0, + 11868.0, + 11421.0, + 11028.0, + 10280.0, + 9812.0, + 9542.0, + 9013.0, + 8767.0, + 8309.0, + 7774.0, + 7274.0, + 6706.0, + 6641.0, + 6308.0, + 5835.0, + 5373.0, + 4902.0, + 4802.0, + 4458.0, + 4473.0, + 4047.0, + 3590.0, + 3618.0, + 3293.0, + 3088.0, + 2915.0, + 2868.0, + 36004.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 3.0, + 10.0, + 8.0, + 6.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 1408.0, + 3297.0, + 1432.0, + 8238.0, + 6157.0, + 4237.0, + 3728.0, + 5128.0, + 3148.0, + 2624.0, + 2641.0, + 2701.0, + 3244.0, + 23567.0, + 15565.0, + 4315.0, + 3636.0, + 4151.0, + 4762.0, + 4568.0, + 4798.0, + 4977.0, + 5224.0, + 5327.0, + 7099.0, + 12805.0, + 12997.0, + 12562.0, + 11978.0, + 8666.0, + 7410.0, + 7655.0, + 14927.0, + 39738.0, + 9040.0, + 12946.0, + 12438.0, + 17329.0, + 12460.0, + 10077.0, + 11788.0, + 23598.0, + 16279.0, + 9980.0, + 6790.0, + 6412.0, + 6332.0, + 6425.0, + 8331.0, + 7334.0, + 6134.0, + 6774.0, + 5967.0, + 7015.0, + 11089.0, + 8620.0, + 6348.0, + 5036.0, + 5704.0, + 5512.0, + 6173.0, + 6225.0, + 5863.0, + 5293.0, + 5027.0, + 4266.0, + 4514.0, + 4296.0, + 3881.0, + 3405.0, + 3267.0, + 3276.0, + 3031.0, + 3079.0, + 3090.0, + 2951.0, + 2362.0, + 2503.0, + 2391.0, + 2294.0, + 1742.0, + 1632.0, + 1648.0, + 1578.0, + 1496.0, + 1371.0, + 1264.0, + 1185.0, + 1215.0, + 1134.0, + 1079.0, + 1012.0, + 818.0, + 830.0, + 899.0, + 832.0, + 899.0, + 849.0, + 759.0, + 705.0, + 571.0, + 417.0, + 533.0, + 434.0, + 423.0, + 332.0, + 321.0, + 275.0, + 248.0, + 278.0, + 259.0, + 255.0, + 218.0, + 202.0, + 203.0, + 155.0, + 160.0, + 139.0, + 150.0, + 170.0, + 199.0, + 153.0, + 1451.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12013.0, + 9751.0, + 13631.0, + 9078.0, + 4461.0, + 2968.0, + 2745.0, + 2895.0, + 2554.0, + 2646.0, + 2558.0, + 2882.0, + 3023.0, + 4986.0, + 4120.0, + 4589.0, + 4414.0, + 4756.0, + 5196.0, + 7821.0, + 5389.0, + 5319.0, + 5252.0, + 6191.0, + 14961.0, + 15417.0, + 13134.0, + 12029.0, + 9966.0, + 7021.0, + 7062.0, + 8456.0, + 10481.0, + 37080.0, + 9449.0, + 17460.0, + 12396.0, + 14730.0, + 10140.0, + 10269.0, + 13365.0, + 27763.0, + 17353.0, + 35353.0, + 8386.0, + 7315.0, + 8224.0, + 6667.0, + 6976.0, + 8224.0, + 6763.0, + 6565.0, + 7025.0, + 8410.0, + 9459.0, + 8081.0, + 8197.0, + 6862.0, + 7009.0, + 5961.0, + 5840.0, + 6311.0, + 5553.0, + 5312.0, + 5122.0, + 5091.0, + 4614.0, + 4203.0, + 3749.0, + 3938.0, + 4622.0, + 4145.0, + 3274.0, + 3805.0, + 3722.0, + 3505.0, + 3301.0, + 2849.0, + 2655.0, + 2691.0, + 2515.0, + 2517.0, + 2253.0, + 2263.0, + 2279.0, + 2242.0, + 2101.0, + 1926.0, + 1992.0, + 1764.0, + 1657.0, + 1577.0, + 1528.0, + 1400.0, + 1432.0, + 1384.0, + 1412.0, + 1433.0, + 1140.0, + 1123.0, + 1112.0, + 1123.0, + 932.0, + 974.0, + 821.0, + 776.0, + 707.0, + 746.0, + 664.0, + 593.0, + 503.0, + 506.0, + 425.0, + 401.0, + 388.0, + 363.0, + 359.0, + 357.0, + 433.0, + 415.0, + 319.0, + 316.0, + 3537.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 2.0, + 5.0, + 24.0, + 10.0, + 11.0, + 11.0, + 12.0, + 26.0, + 10.0, + 9.0, + 6.0, + 2.0, + 13.0, + 18.0, + 37.0, + 43.0, + 2.0, + 2.0, + 1.0, + 1.0, + 3.0, + 3.0, + 6.0, + 23.0, + 15.0, + 67.0, + 11.0, + 2.0, + 2.0, + 4.0, + 6.0, + 1.0, + 115.0, + 12.0, + 2.0, + 6.0, + 49.0, + 26.0, + 36.0, + 32.0, + 62.0, + 10.0, + 6.0, + 3.0, + 4.0, + 3.0, + 4.0, + 5.0, + 5.0, + 3.0, + 0.0, + 3.0, + 5.0, + 2.0, + 2.0, + 20.0, + 21.0, + 2.0, + 2.0, + 2.0, + 2.0, + 4.0, + 3.0, + 0.0, + 4.0, + 6.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 3.0, + 9.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 6.0, + 0.0, + 8.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 5.0, + 5.0, + 0.0, + 2.0, + 6.0, + 3.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 33.0, + 9296.0, + 1568.0, + 548.0, + 654.0, + 407.0, + 322.0, + 393.0, + 424.0, + 553.0, + 472.0, + 542.0, + 565.0, + 1534.0, + 980.0, + 861.0, + 793.0, + 682.0, + 668.0, + 752.0, + 877.0, + 750.0, + 885.0, + 821.0, + 1646.0, + 6646.0, + 2257.0, + 1019.0, + 956.0, + 5710.0, + 792.0, + 724.0, + 765.0, + 2438.0, + 15126.0, + 1005.0, + 1091.0, + 980.0, + 1110.0, + 1792.0, + 1820.0, + 1418.0, + 1176.0, + 1167.0, + 904.0, + 886.0, + 739.0, + 962.0, + 979.0, + 1548.0, + 767.0, + 785.0, + 939.0, + 1050.0, + 1654.0, + 1985.0, + 1902.0, + 1155.0, + 1276.0, + 1253.0, + 1246.0, + 1161.0, + 689.0, + 537.0, + 631.0, + 888.0, + 623.0, + 691.0, + 471.0, + 405.0, + 380.0, + 432.0, + 414.0, + 438.0, + 478.0, + 583.0, + 420.0, + 359.0, + 361.0, + 471.0, + 334.0, + 239.0, + 264.0, + 359.0, + 247.0, + 194.0, + 190.0, + 174.0, + 132.0, + 123.0, + 213.0, + 188.0, + 118.0, + 82.0, + 73.0, + 120.0, + 120.0, + 119.0, + 91.0, + 42.0, + 68.0, + 62.0, + 76.0, + 55.0, + 28.0, + 40.0, + 32.0, + 31.0, + 35.0, + 25.0, + 29.0, + 17.0, + 25.0, + 24.0, + 17.0, + 40.0, + 19.0, + 35.0, + 107.0, + 170.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 9691.0, + 10414.0, + 16777216.0, + 16733855.0, + 16777216.0, + 1492.0, + 10.0, + 0.0, + 30.0, + 111929.0, + 1708659.0, + 4551423.0, + 16777216.0, + 16777216.0, + 16777216.0, + 2834846.0, + 16777216.0, + 9644045.0, + 6890359.0, + 7441590.0, + 7029367.0, + 6485800.0, + 6991918.0, + 8213395.0, + 11772377.0, + 16777216.0, + 16777216.0, + 12764673.0, + 8414636.0, + 9084040.0, + 12098492.0, + 16777216.0, + 16777216.0, + 15406039.0, + 15145456.0, + 14706841.0, + 16777216.0, + 16777216.0, + 16525131.0, + 16777216.0, + 15487702.0, + 12803484.0, + 12377947.0, + 11888164.0, + 11801750.0, + 11921907.0, + 12396884.0, + 13106420.0, + 13002931.0, + 13289458.0, + 11759340.0, + 11296732.0, + 12004121.0, + 13045113.0, + 12319747.0, + 10589549.0, + 10031468.0, + 9638072.0, + 9422025.0, + 9384838.0, + 9452510.0, + 9214291.0, + 8321873.0, + 7657919.0, + 7411377.0, + 7106317.0, + 6805594.0, + 6633282.0, + 6437449.0, + 6175092.0, + 5956855.0, + 5779942.0, + 5758829.0, + 5552579.0, + 5468595.0, + 5270037.0, + 4942259.0, + 4608753.0, + 4269413.0, + 4030622.0, + 3829832.0, + 3594723.0, + 3379917.0, + 3256991.0, + 3091995.0, + 2919693.0, + 2747090.0, + 2624261.0, + 2521294.0, + 2434546.0, + 2258778.0, + 2107473.0, + 2008607.0, + 1912623.0, + 1861684.0, + 1787000.0, + 1686830.0, + 1586326.0, + 1486593.0, + 1385786.0, + 1294029.0, + 1214072.0, + 1126996.0, + 1040460.0, + 976024.0, + 907814.0, + 854011.0, + 807667.0, + 758972.0, + 714886.0, + 674929.0, + 632501.0, + 593628.0, + 551603.0, + 514926.0, + 488257.0, + 460680.0, + 436028.0, + 404396.0, + 374381.0, + 344612.0, + 318620.0, + 297477.0, + 275407.0, + 253815.0, + 233380.0, + 215112.0, + 198971.0, + 183839.0, + 170497.0, + 159640.0, + 148281.0, + 137656.0, + 127670.0, + 117886.0, + 108878.0, + 103101.0, + 94854.0, + 997182.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11216.0, + 985980.0, + 1298.0, + 0.0, + 0.0, + 4.0, + 0.0, + 0.0, + 0.0, + 41.0, + 1287545.0, + 599712.0, + 21907.0, + 21501.0, + 183050.0, + 107053.0, + 74288.0, + 77134.0, + 82066.0, + 87437.0, + 92448.0, + 103928.0, + 135483.0, + 1748837.0, + 256008.0, + 145282.0, + 194476.0, + 144027.0, + 765530.0, + 403661.0, + 131406.0, + 140383.0, + 152892.0, + 154724.0, + 154705.0, + 240597.0, + 194802.0, + 169386.0, + 173072.0, + 171898.0, + 146805.0, + 139983.0, + 146190.0, + 261497.0, + 244223.0, + 173075.0, + 181389.0, + 209432.0, + 178687.0, + 177987.0, + 210584.0, + 286784.0, + 268588.0, + 199337.0, + 179991.0, + 165877.0, + 162359.0, + 175713.0, + 163730.0, + 158489.0, + 147260.0, + 144036.0, + 147080.0, + 160093.0, + 173223.0, + 189434.0, + 155542.0, + 128736.0, + 125093.0, + 133449.0, + 139459.0, + 132576.0, + 124011.0, + 123273.0, + 115842.0, + 116117.0, + 110401.0, + 97580.0, + 89714.0, + 84919.0, + 81950.0, + 80647.0, + 80277.0, + 74760.0, + 76866.0, + 79073.0, + 75944.0, + 75823.0, + 66038.0, + 61072.0, + 58246.0, + 57924.0, + 56997.0, + 52944.0, + 47638.0, + 44790.0, + 43031.0, + 41919.0, + 39968.0, + 37287.0, + 35161.0, + 33124.0, + 30473.0, + 28551.0, + 28910.0, + 27472.0, + 26158.0, + 24821.0, + 23686.0, + 22607.0, + 21106.0, + 20019.0, + 19025.0, + 17667.0, + 16081.0, + 15417.0, + 14724.0, + 13053.0, + 12261.0, + 11438.0, + 10766.0, + 10235.0, + 9628.0, + 9063.0, + 8329.0, + 7761.0, + 7318.0, + 6769.0, + 6452.0, + 5816.0, + 5332.0, + 5119.0, + 4980.0, + 53738.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1207837.0, + 337.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 83.0, + 1710373.0, + 52231.0, + 669.0, + 14928.0, + 189246.0, + 257192.0, + 187993.0, + 133319.0, + 126793.0, + 127691.0, + 124010.0, + 172136.0, + 252790.0, + 1339270.0, + 129973.0, + 106898.0, + 133406.0, + 479361.0, + 668503.0, + 302768.0, + 244572.0, + 237626.0, + 220627.0, + 261010.0, + 323620.0, + 288751.0, + 462819.0, + 222574.0, + 266234.0, + 224847.0, + 201305.0, + 203612.0, + 270431.0, + 299022.0, + 218865.0, + 230248.0, + 223584.0, + 244435.0, + 238574.0, + 267067.0, + 342354.0, + 372697.0, + 272471.0, + 246240.0, + 246457.0, + 240403.0, + 253306.0, + 255434.0, + 252356.0, + 245087.0, + 227186.0, + 222968.0, + 222530.0, + 234434.0, + 242766.0, + 224007.0, + 190087.0, + 188180.0, + 186765.0, + 195269.0, + 191990.0, + 190984.0, + 183856.0, + 176210.0, + 164498.0, + 160730.0, + 155587.0, + 147940.0, + 135670.0, + 129032.0, + 126547.0, + 124479.0, + 118675.0, + 119231.0, + 118757.0, + 115623.0, + 114371.0, + 102235.0, + 97611.0, + 93860.0, + 91046.0, + 89622.0, + 84349.0, + 80347.0, + 75656.0, + 73772.0, + 71286.0, + 68914.0, + 64262.0, + 60327.0, + 57768.0, + 53475.0, + 50364.0, + 47789.0, + 46961.0, + 45575.0, + 42967.0, + 40322.0, + 38187.0, + 37349.0, + 35021.0, + 33183.0, + 31174.0, + 29150.0, + 27707.0, + 26161.0, + 24550.0, + 22651.0, + 21712.0, + 20541.0, + 19526.0, + 18354.0, + 16773.0, + 15852.0, + 14688.0, + 13966.0, + 13780.0, + 12984.0, + 12104.0, + 11298.0, + 10266.0, + 9463.0, + 9156.0, + 8550.0, + 8067.0, + 7301.0, + 83074.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 16777216.0, + 3237923.0, + 0.0, + 0.0, + 0.0, + 2.0, + 22.0, + 343.0, + 179137.0, + 1411717.0, + 9522256.0, + 16777216.0, + 4832728.0, + 1121233.0, + 7611579.0, + 15619468.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16274952.0, + 12956712.0, + 15197662.0, + 16777216.0, + 16777216.0, + 16777216.0, + 11874594.0, + 13142701.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16741527.0, + 16165378.0, + 15365263.0, + 14566258.0, + 13937879.0, + 13406922.0, + 12890622.0, + 12316187.0, + 11821143.0, + 11369901.0, + 10856282.0, + 10285826.0, + 9935593.0, + 9563312.0, + 9072129.0, + 8369714.0, + 7933340.0, + 7627369.0, + 7394943.0, + 7167773.0, + 6870790.0, + 6522040.0, + 6160984.0, + 5838375.0, + 5550554.0, + 5280310.0, + 4995515.0, + 4698141.0, + 4418727.0, + 4159822.0, + 3914405.0, + 3704339.0, + 3526126.0, + 3337198.0, + 3146925.0, + 2963353.0, + 2775887.0, + 2615663.0, + 2465414.0, + 2327741.0, + 2204637.0, + 2074997.0, + 1958205.0, + 1840516.0, + 1728502.0, + 1616884.0, + 1512932.0, + 1421843.0, + 1325314.0, + 1235491.0, + 1151697.0, + 1072233.0, + 999065.0, + 937037.0, + 879671.0, + 820826.0, + 766528.0, + 713625.0, + 662949.0, + 618276.0, + 576218.0, + 539449.0, + 503835.0, + 469834.0, + 434936.0, + 403956.0, + 4685084.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 470.0, + 4057.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 575.0, + 8221.0, + 355.0, + 1008.0, + 749.0, + 191.0, + 294.0, + 495.0, + 528.0, + 430.0, + 497.0, + 707.0, + 814.0, + 918.0, + 9263.0, + 1293.0, + 1025.0, + 917.0, + 1037.0, + 4958.0, + 1350.0, + 643.0, + 630.0, + 563.0, + 598.0, + 763.0, + 2076.0, + 1155.0, + 782.0, + 700.0, + 681.0, + 645.0, + 763.0, + 592.0, + 1488.0, + 666.0, + 623.0, + 713.0, + 662.0, + 702.0, + 802.0, + 1021.0, + 952.0, + 1049.0, + 575.0, + 536.0, + 520.0, + 497.0, + 523.0, + 635.0, + 669.0, + 624.0, + 443.0, + 512.0, + 684.0, + 1001.0, + 1224.0, + 546.0, + 508.0, + 594.0, + 646.0, + 622.0, + 615.0, + 634.0, + 639.0, + 562.0, + 616.0, + 288.0, + 242.0, + 179.0, + 177.0, + 231.0, + 236.0, + 229.0, + 188.0, + 187.0, + 227.0, + 239.0, + 292.0, + 216.0, + 153.0, + 155.0, + 169.0, + 118.0, + 103.0, + 109.0, + 91.0, + 116.0, + 102.0, + 92.0, + 101.0, + 71.0, + 67.0, + 58.0, + 49.0, + 58.0, + 46.0, + 81.0, + 62.0, + 59.0, + 54.0, + 39.0, + 43.0, + 32.0, + 36.0, + 37.0, + 30.0, + 25.0, + 23.0, + 27.0, + 17.0, + 21.0, + 20.0, + 27.0, + 16.0, + 18.0, + 13.0, + 11.0, + 13.0, + 13.0, + 61.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 205213.0, + 1536002.0, + 16777216.0, + 13162792.0, + 38379.0, + 713.0, + 222.0, + 163.0, + 734.0, + 103292.0, + 1794195.0, + 3246144.0, + 8469664.0, + 16777216.0, + 2913603.0, + 1270938.0, + 3201236.0, + 3938133.0, + 3574196.0, + 3208352.0, + 2905524.0, + 2659932.0, + 2852215.0, + 3946684.0, + 7063621.0, + 13903994.0, + 7418893.0, + 3158508.0, + 3143287.0, + 4337348.0, + 8030970.0, + 7369210.0, + 6003721.0, + 5368726.0, + 5394833.0, + 5698419.0, + 6376751.0, + 5930910.0, + 5929728.0, + 5855273.0, + 4729752.0, + 4136089.0, + 3680238.0, + 3629515.0, + 3734471.0, + 4057707.0, + 3893493.0, + 4105188.0, + 4036019.0, + 3607797.0, + 3265133.0, + 3517882.0, + 3801404.0, + 3971699.0, + 3313661.0, + 2933338.0, + 2739063.0, + 2531574.0, + 2485980.0, + 2600517.0, + 2479980.0, + 2263244.0, + 2098755.0, + 1954718.0, + 1884145.0, + 1779987.0, + 1740407.0, + 1650343.0, + 1525992.0, + 1389294.0, + 1371465.0, + 1337786.0, + 1334879.0, + 1299691.0, + 1234777.0, + 1191357.0, + 1081594.0, + 973481.0, + 888408.0, + 831939.0, + 765092.0, + 724362.0, + 679616.0, + 642482.0, + 609975.0, + 561306.0, + 541095.0, + 524424.0, + 504424.0, + 466207.0, + 412682.0, + 392179.0, + 367987.0, + 355437.0, + 351236.0, + 323431.0, + 296725.0, + 277743.0, + 255284.0, + 235023.0, + 215916.0, + 196561.0, + 183656.0, + 170506.0, + 152170.0, + 138454.0, + 131473.0, + 125528.0, + 121564.0, + 115143.0, + 107032.0, + 99979.0, + 90046.0, + 83952.0, + 78794.0, + 75431.0, + 68470.0, + 62558.0, + 57420.0, + 52754.0, + 47430.0, + 44280.0, + 41662.0, + 38834.0, + 34543.0, + 32269.0, + 29485.0, + 25866.0, + 23908.0, + 21487.0, + 21457.0, + 20403.0, + 17653.0, + 15864.0, + 14932.0, + 14170.0, + 13607.0, + 12728.0, + 119104.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 284121.0, + 7419517.0, + 6517273.0, + 5707000.0, + 785.0, + 185.0, + 172.0, + 63.0, + 102.0, + 315.0, + 273726.0, + 2218632.0, + 10473215.0, + 10382370.0, + 391168.0, + 4589231.0, + 4064099.0, + 6370094.0, + 5756328.0, + 3582974.0, + 3248198.0, + 3218980.0, + 3095578.0, + 4213472.0, + 8057046.0, + 12108337.0, + 5095899.0, + 5254219.0, + 4279860.0, + 5313246.0, + 8542249.0, + 6987219.0, + 6454572.0, + 5074962.0, + 5055485.0, + 5477671.0, + 5998232.0, + 5586851.0, + 6511321.0, + 6190620.0, + 5247080.0, + 5926064.0, + 4182641.0, + 4111784.0, + 4242975.0, + 4598278.0, + 3919748.0, + 4392285.0, + 4432022.0, + 4051124.0, + 3899645.0, + 4281475.0, + 4511377.0, + 4203197.0, + 3785215.0, + 3638853.0, + 3227336.0, + 3064032.0, + 3138503.0, + 3250743.0, + 3108747.0, + 2964461.0, + 2916173.0, + 2720623.0, + 2605906.0, + 2617580.0, + 2560839.0, + 2444363.0, + 2241942.0, + 2055349.0, + 2060728.0, + 2084862.0, + 2045498.0, + 1991233.0, + 1935568.0, + 1809860.0, + 1696593.0, + 1612348.0, + 1520149.0, + 1367301.0, + 1280648.0, + 1222721.0, + 1169165.0, + 1114330.0, + 1055017.0, + 1009525.0, + 990574.0, + 954005.0, + 906327.0, + 850208.0, + 787555.0, + 740822.0, + 702866.0, + 686744.0, + 668620.0, + 615282.0, + 586775.0, + 552728.0, + 519758.0, + 487752.0, + 454242.0, + 423554.0, + 391085.0, + 362009.0, + 333750.0, + 310766.0, + 296638.0, + 280432.0, + 268138.0, + 254861.0, + 242574.0, + 229866.0, + 217388.0, + 202129.0, + 193494.0, + 178708.0, + 164162.0, + 154736.0, + 144843.0, + 135296.0, + 126243.0, + 117316.0, + 108738.0, + 100689.0, + 93146.0, + 87002.0, + 79809.0, + 74288.0, + 68414.0, + 63765.0, + 59794.0, + 55652.0, + 51932.0, + 48625.0, + 45523.0, + 42765.0, + 39544.0, + 35735.0, + 393043.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1532.0, + 19045.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 124.0, + 5318.0, + 20382.0, + 22221.0, + 9753.0, + 2563.0, + 5846.0, + 5114.0, + 7566.0, + 6059.0, + 6046.0, + 5629.0, + 4204.0, + 5387.0, + 9828.0, + 8249.0, + 17088.0, + 8340.0, + 3500.0, + 4656.0, + 8373.0, + 14549.0, + 7394.0, + 5603.0, + 5419.0, + 7039.0, + 7503.0, + 8793.0, + 9003.0, + 5703.0, + 3909.0, + 2993.0, + 2579.0, + 2423.0, + 2355.0, + 2492.0, + 2663.0, + 2543.0, + 3463.0, + 3187.0, + 2487.0, + 1976.0, + 2093.0, + 2682.0, + 2798.0, + 1735.0, + 1518.0, + 1478.0, + 1350.0, + 1261.0, + 1219.0, + 2035.0, + 1187.0, + 1113.0, + 1077.0, + 1014.0, + 746.0, + 755.0, + 657.0, + 528.0, + 398.0, + 456.0, + 454.0, + 625.0, + 398.0, + 447.0, + 518.0, + 231.0, + 282.0, + 278.0, + 269.0, + 259.0, + 242.0, + 332.0, + 232.0, + 202.0, + 184.0, + 173.0, + 119.0, + 162.0, + 122.0, + 118.0, + 100.0, + 117.0, + 154.0, + 116.0, + 127.0, + 138.0, + 85.0, + 90.0, + 60.0, + 84.0, + 68.0, + 80.0, + 42.0, + 32.0, + 29.0, + 37.0, + 40.0, + 30.0, + 29.0, + 28.0, + 11.0, + 4.0, + 11.0, + 10.0, + 8.0, + 7.0, + 17.0, + 12.0, + 13.0, + 9.0, + 13.0, + 9.0, + 4.0, + 3.0, + 7.0, + 4.0, + 3.0, + 1.0, + 4.0, + 1.0, + 0.0, + 3.0, + 2.0, + 6.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 203.0, + 324983.0, + 3421583.0, + 1396329.0, + 2920.0, + 119.0, + 311.0, + 175.0, + 27385.0, + 27152.0, + 1150358.0, + 1842361.0, + 3123012.0, + 974583.0, + 302157.0, + 513934.0, + 681633.0, + 647147.0, + 712472.0, + 548797.0, + 607424.0, + 526180.0, + 673603.0, + 1272144.0, + 1692418.0, + 3078377.0, + 804278.0, + 634750.0, + 845249.0, + 1371586.0, + 2040580.0, + 993063.0, + 964099.0, + 859726.0, + 1030160.0, + 1090462.0, + 1152569.0, + 1159777.0, + 858254.0, + 727137.0, + 688979.0, + 633244.0, + 618671.0, + 702146.0, + 637024.0, + 862300.0, + 784069.0, + 952670.0, + 723836.0, + 629671.0, + 668601.0, + 782701.0, + 817445.0, + 651539.0, + 628185.0, + 511604.0, + 498371.0, + 494539.0, + 466236.0, + 480304.0, + 428674.0, + 359324.0, + 369848.0, + 348739.0, + 345596.0, + 337808.0, + 344050.0, + 359611.0, + 278919.0, + 255113.0, + 271685.0, + 272485.0, + 266000.0, + 251577.0, + 234241.0, + 212091.0, + 192300.0, + 182410.0, + 173972.0, + 159815.0, + 139345.0, + 137643.0, + 126678.0, + 118859.0, + 109708.0, + 103647.0, + 102453.0, + 102069.0, + 93716.0, + 89999.0, + 77563.0, + 73425.0, + 70768.0, + 70091.0, + 66296.0, + 56754.0, + 52516.0, + 48562.0, + 43991.0, + 40593.0, + 36922.0, + 36437.0, + 34062.0, + 30261.0, + 26329.0, + 25629.0, + 24032.0, + 22835.0, + 22976.0, + 20720.0, + 19295.0, + 17288.0, + 17282.0, + 15377.0, + 14893.0, + 13147.0, + 12539.0, + 10651.0, + 9460.0, + 8236.0, + 7614.0, + 6818.0, + 6766.0, + 6078.0, + 5610.0, + 5083.0, + 4604.0, + 4504.0, + 4446.0, + 4228.0, + 4082.0, + 3516.0, + 2949.0, + 2657.0, + 24538.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 26678.0, + 3447.0, + 3789.0, + 30264.0, + 813.0, + 1485.0, + 695.0, + 909.0, + 606.0, + 534.0, + 529.0, + 469.0, + 452.0, + 535.0, + 1143.0, + 550.0, + 583.0, + 608.0, + 650.0, + 540.0, + 584.0, + 545.0, + 608.0, + 780.0, + 811.0, + 62020.0, + 9775.0, + 662.0, + 550.0, + 1119.0, + 605.0, + 674.0, + 753.0, + 5672.0, + 7789.0, + 633.0, + 581.0, + 554.0, + 576.0, + 546.0, + 587.0, + 620.0, + 841.0, + 823.0, + 584.0, + 679.0, + 541.0, + 514.0, + 632.0, + 479.0, + 425.0, + 673.0, + 540.0, + 557.0, + 582.0, + 570.0, + 721.0, + 588.0, + 513.0, + 527.0, + 606.0, + 616.0, + 488.0, + 429.0, + 449.0, + 420.0, + 431.0, + 521.0, + 469.0, + 529.0, + 434.0, + 389.0, + 398.0, + 400.0, + 418.0, + 331.0, + 371.0, + 376.0, + 359.0, + 319.0, + 318.0, + 326.0, + 277.0, + 311.0, + 298.0, + 251.0, + 284.0, + 245.0, + 229.0, + 229.0, + 247.0, + 177.0, + 274.0, + 173.0, + 168.0, + 162.0, + 237.0, + 304.0, + 226.0, + 164.0, + 161.0, + 148.0, + 157.0, + 121.0, + 141.0, + 106.0, + 126.0, + 114.0, + 121.0, + 129.0, + 139.0, + 84.0, + 71.0, + 68.0, + 68.0, + 84.0, + 70.0, + 68.0, + 58.0, + 48.0, + 45.0, + 754.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 612.0, + 0.0, + 10.0, + 120.0, + 7949.0, + 3676.0, + 342.0, + 394.0, + 666.0, + 726.0, + 699.0, + 723.0, + 669.0, + 676.0, + 729.0, + 735.0, + 826.0, + 802.0, + 2900.0, + 949.0, + 940.0, + 935.0, + 1037.0, + 992.0, + 1886.0, + 25464.0, + 2066.0, + 6445.0, + 4828.0, + 2733.0, + 2890.0, + 4448.0, + 3714.0, + 4022.0, + 1987.0, + 2878.0, + 2341.0, + 7796.0, + 3490.0, + 1439.0, + 5356.0, + 7321.0, + 1502.0, + 1308.0, + 1317.0, + 1308.0, + 1283.0, + 1183.0, + 1374.0, + 1443.0, + 994.0, + 1039.0, + 1080.0, + 1195.0, + 1054.0, + 1132.0, + 1219.0, + 1246.0, + 1169.0, + 1143.0, + 1145.0, + 1227.0, + 1186.0, + 1252.0, + 1295.0, + 1317.0, + 1207.0, + 1050.0, + 1060.0, + 1050.0, + 1030.0, + 1059.0, + 1135.0, + 1053.0, + 929.0, + 1014.0, + 1147.0, + 873.0, + 861.0, + 864.0, + 829.0, + 868.0, + 864.0, + 840.0, + 697.0, + 784.0, + 878.0, + 671.0, + 649.0, + 675.0, + 591.0, + 573.0, + 516.0, + 551.0, + 485.0, + 536.0, + 565.0, + 512.0, + 490.0, + 469.0, + 409.0, + 496.0, + 451.0, + 392.0, + 403.0, + 366.0, + 369.0, + 372.0, + 357.0, + 351.0, + 360.0, + 298.0, + 293.0, + 284.0, + 258.0, + 279.0, + 208.0, + 225.0, + 221.0, + 231.0, + 227.0, + 236.0, + 227.0, + 223.0, + 2792.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 24.0, + 784.0, + 2023.0, + 5992.0, + 63611.0, + 40246.0, + 64344.0, + 62179.0, + 199710.0, + 1265566.0, + 107630.0, + 67994.0, + 61316.0, + 56048.0, + 58655.0, + 61556.0, + 61781.0, + 85545.0, + 83789.0, + 89527.0, + 95611.0, + 123470.0, + 145845.0, + 106814.0, + 120220.0, + 131663.0, + 162438.0, + 161515.0, + 207940.0, + 1431739.0, + 186990.0, + 185247.0, + 193563.0, + 167033.0, + 159596.0, + 168959.0, + 563029.0, + 180348.0, + 174399.0, + 193121.0, + 225362.0, + 193479.0, + 182588.0, + 192227.0, + 263279.0, + 395729.0, + 259756.0, + 235811.0, + 242212.0, + 210840.0, + 210260.0, + 213902.0, + 209905.0, + 194051.0, + 194831.0, + 201401.0, + 201834.0, + 213909.0, + 234323.0, + 186803.0, + 180483.0, + 187901.0, + 190391.0, + 192823.0, + 200911.0, + 204339.0, + 185795.0, + 184290.0, + 180391.0, + 181273.0, + 170565.0, + 152799.0, + 151223.0, + 143720.0, + 137073.0, + 131708.0, + 126803.0, + 124575.0, + 122209.0, + 119296.0, + 118583.0, + 114746.0, + 106949.0, + 98957.0, + 97451.0, + 97408.0, + 96181.0, + 92885.0, + 88846.0, + 83396.0, + 78558.0, + 76966.0, + 71837.0, + 69172.0, + 64607.0, + 60585.0, + 56279.0, + 53890.0, + 52386.0, + 52791.0, + 50285.0, + 46232.0, + 43709.0, + 41979.0, + 40472.0, + 39243.0, + 36185.0, + 35036.0, + 32925.0, + 31664.0, + 29503.0, + 28101.0, + 25978.0, + 24986.0, + 23625.0, + 22482.0, + 21452.0, + 19421.0, + 18441.0, + 17763.0, + 17140.0, + 15509.0, + 15062.0, + 13757.0, + 12902.0, + 12146.0, + 11481.0, + 10918.0, + 10242.0, + 9670.0, + 116568.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 30.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 3.0, + 1.0, + 4.0, + 3.0, + 5.0, + 2.0, + 4.0, + 5.0, + 23.0, + 16.0, + 4.0, + 3.0, + 11.0, + 1.0, + 4.0, + 4.0, + 1.0, + 0.0, + 1.0, + 3.0, + 9.0, + 7.0, + 7.0, + 3.0, + 1.0, + 7.0, + 5.0, + 4.0, + 4.0, + 0.0, + 0.0, + 5.0, + 13.0, + 8.0, + 0.0, + 0.0, + 2.0, + 9.0, + 2.0, + 8.0, + 5.0, + 4.0, + 5.0, + 5.0, + 0.0, + 1.0, + 2.0, + 1.0, + 6.0, + 11.0, + 3.0, + 5.0, + 1.0, + 6.0, + 7.0, + 15.0, + 4.0, + 3.0, + 11.0, + 3.0, + 0.0, + 2.0, + 3.0, + 19.0, + 2.0, + 2.0, + 0.0, + 8.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 0.0, + 2.0, + 3.0, + 1.0, + 2.0, + 0.0, + 0.0, + 3.0, + 2.0, + 3.0, + 1.0, + 2.0, + 3.0, + 2.0, + 2.0, + 1.0, + 4.0, + 1.0, + 4.0, + 22.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 81.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 25166.0, + 9282.0, + 4337.0, + 8081.0, + 77921.0, + 68804.0, + 43989.0, + 28776.0, + 23761.0, + 20277.0, + 17079.0, + 15773.0, + 16880.0, + 41471.0, + 78518.0, + 37931.0, + 29351.0, + 25577.0, + 28600.0, + 35028.0, + 25662.0, + 27301.0, + 28266.0, + 31555.0, + 30389.0, + 90964.0, + 110013.0, + 34747.0, + 35784.0, + 32966.0, + 29393.0, + 28817.0, + 35030.0, + 97522.0, + 67080.0, + 32706.0, + 35552.0, + 41484.0, + 42315.0, + 34260.0, + 42531.0, + 89322.0, + 108992.0, + 57023.0, + 39311.0, + 30147.0, + 28504.0, + 32172.0, + 31087.0, + 28341.0, + 27314.0, + 29431.0, + 28034.0, + 28669.0, + 41944.0, + 41390.0, + 34835.0, + 21926.0, + 22431.0, + 22901.0, + 22857.0, + 25005.0, + 25107.0, + 21942.0, + 20506.0, + 19722.0, + 19471.0, + 20511.0, + 16113.0, + 13887.0, + 13516.0, + 12150.0, + 11139.0, + 10712.0, + 10641.0, + 11387.0, + 10815.0, + 10813.0, + 9738.0, + 9618.0, + 8393.0, + 7442.0, + 7267.0, + 6590.0, + 6893.0, + 6288.0, + 5843.0, + 5476.0, + 4951.0, + 4653.0, + 4451.0, + 3930.0, + 3667.0, + 3651.0, + 3423.0, + 3356.0, + 3338.0, + 2947.0, + 2770.0, + 2731.0, + 2566.0, + 2348.0, + 2071.0, + 1951.0, + 1939.0, + 1838.0, + 1615.0, + 1388.0, + 1213.0, + 1213.0, + 1011.0, + 1011.0, + 911.0, + 791.0, + 796.0, + 764.0, + 646.0, + 753.0, + 796.0, + 604.0, + 558.0, + 582.0, + 469.0, + 425.0, + 4003.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 78.0, + 237.0, + 16202.0, + 63726.0, + 30976.0, + 34862.0, + 26700.0, + 25480.0, + 19475.0, + 17541.0, + 17329.0, + 17548.0, + 15782.0, + 17354.0, + 24001.0, + 21218.0, + 25487.0, + 32275.0, + 29333.0, + 28382.0, + 41536.0, + 32229.0, + 31235.0, + 33886.0, + 46013.0, + 52537.0, + 61719.0, + 46326.0, + 38251.0, + 32326.0, + 28835.0, + 30394.0, + 31816.0, + 108119.0, + 29589.0, + 32439.0, + 39175.0, + 45434.0, + 40968.0, + 34652.0, + 56809.0, + 92400.0, + 70081.0, + 140092.0, + 60694.0, + 31773.0, + 30991.0, + 31381.0, + 32134.0, + 27387.0, + 28777.0, + 27308.0, + 27264.0, + 30524.0, + 35778.0, + 32073.0, + 25099.0, + 24780.0, + 23558.0, + 22239.0, + 21897.0, + 21652.0, + 23248.0, + 21356.0, + 20736.0, + 19804.0, + 19620.0, + 17667.0, + 17226.0, + 16897.0, + 17229.0, + 16121.0, + 14561.0, + 14676.0, + 16906.0, + 14371.0, + 13480.0, + 12593.0, + 11696.0, + 11108.0, + 10192.0, + 9944.0, + 9927.0, + 8873.0, + 8585.0, + 8307.0, + 8231.0, + 7540.0, + 7247.0, + 6958.0, + 6764.0, + 6422.0, + 6036.0, + 5637.0, + 5403.0, + 4810.0, + 5198.0, + 4875.0, + 4157.0, + 4010.0, + 3940.0, + 3710.0, + 3562.0, + 3340.0, + 3255.0, + 3204.0, + 2956.0, + 2551.0, + 2418.0, + 2266.0, + 2207.0, + 1959.0, + 1836.0, + 1727.0, + 1671.0, + 1662.0, + 1440.0, + 1490.0, + 1254.0, + 1208.0, + 1173.0, + 1173.0, + 1116.0, + 1131.0, + 11089.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 30.0, + 115.0, + 363.0, + 111.0, + 56.0, + 83.0, + 17.0, + 36.0, + 14.0, + 16.0, + 36.0, + 15.0, + 13.0, + 17.0, + 30.0, + 96.0, + 168.0, + 68.0, + 48.0, + 30.0, + 59.0, + 77.0, + 72.0, + 23.0, + 48.0, + 16.0, + 54.0, + 48.0, + 67.0, + 22.0, + 21.0, + 21.0, + 21.0, + 31.0, + 51.0, + 74.0, + 34.0, + 75.0, + 101.0, + 92.0, + 217.0, + 230.0, + 78.0, + 53.0, + 37.0, + 20.0, + 17.0, + 40.0, + 20.0, + 8.0, + 6.0, + 4.0, + 3.0, + 6.0, + 36.0, + 16.0, + 30.0, + 24.0, + 24.0, + 24.0, + 19.0, + 20.0, + 21.0, + 23.0, + 21.0, + 7.0, + 53.0, + 9.0, + 30.0, + 3.0, + 4.0, + 9.0, + 16.0, + 7.0, + 0.0, + 13.0, + 23.0, + 6.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 4.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 67.0, + 383.0, + 10087.0, + 7664.0, + 16972.0, + 10970.0, + 6445.0, + 4478.0, + 3986.0, + 3520.0, + 3005.0, + 3183.0, + 3420.0, + 4170.0, + 5195.0, + 10877.0, + 5217.0, + 5945.0, + 5525.0, + 4764.0, + 4576.0, + 4525.0, + 4802.0, + 5913.0, + 7617.0, + 6200.0, + 10960.0, + 21627.0, + 8468.0, + 5216.0, + 5851.0, + 6055.0, + 4235.0, + 4416.0, + 8419.0, + 64939.0, + 5969.0, + 5902.0, + 6827.0, + 7501.0, + 9684.0, + 12596.0, + 8934.0, + 7849.0, + 7301.0, + 6374.0, + 4950.0, + 4463.0, + 5686.0, + 6749.0, + 6206.0, + 5159.0, + 4625.0, + 4726.0, + 4379.0, + 4906.0, + 5850.0, + 9391.0, + 4693.0, + 4108.0, + 3964.0, + 4063.0, + 4028.0, + 3284.0, + 2883.0, + 2801.0, + 3146.0, + 3353.0, + 3946.0, + 3527.0, + 2262.0, + 2158.0, + 2088.0, + 1925.0, + 1912.0, + 1830.0, + 2097.0, + 1905.0, + 1942.0, + 1638.0, + 1759.0, + 1565.0, + 1267.0, + 1219.0, + 1275.0, + 1129.0, + 1066.0, + 970.0, + 944.0, + 972.0, + 752.0, + 719.0, + 742.0, + 688.0, + 543.0, + 511.0, + 502.0, + 505.0, + 526.0, + 516.0, + 423.0, + 445.0, + 313.0, + 402.0, + 334.0, + 290.0, + 265.0, + 268.0, + 213.0, + 221.0, + 219.0, + 252.0, + 260.0, + 162.0, + 157.0, + 159.0, + 139.0, + 145.0, + 96.0, + 149.0, + 75.0, + 87.0, + 832.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 567654.0, + 0.0, + 27.0, + 609.0, + 2025.0, + 12925.0, + 39732.0, + 6738.0, + 4448.0, + 1584.0, + 1295.0, + 1057.0, + 1033.0, + 2511.0, + 9407.0, + 1260.0, + 1655.0, + 2931.0, + 3050.0, + 7097.0, + 9001.0, + 3555.0, + 1762.0, + 1644.0, + 1894.0, + 17393.0, + 36144.0, + 3674.0, + 3953.0, + 3029.0, + 2658.0, + 2673.0, + 2819.0, + 16780.0, + 2731.0, + 2700.0, + 2795.0, + 2749.0, + 3008.0, + 2902.0, + 3665.0, + 4638.0, + 3786.0, + 3482.0, + 3554.0, + 3246.0, + 2865.0, + 3023.0, + 3140.0, + 3061.0, + 2074.0, + 1807.0, + 1695.0, + 1686.0, + 1807.0, + 1983.0, + 1867.0, + 1909.0, + 1696.0, + 1730.0, + 1675.0, + 1829.0, + 1825.0, + 1674.0, + 1794.0, + 1894.0, + 1849.0, + 1817.0, + 1846.0, + 1766.0, + 1816.0, + 1770.0, + 1570.0, + 1567.0, + 1414.0, + 1484.0, + 1667.0, + 1412.0, + 1309.0, + 1260.0, + 1289.0, + 1209.0, + 1272.0, + 1297.0, + 1113.0, + 1034.0, + 997.0, + 1079.0, + 774.0, + 821.0, + 848.0, + 805.0, + 766.0, + 751.0, + 684.0, + 661.0, + 624.0, + 664.0, + 632.0, + 616.0, + 583.0, + 586.0, + 561.0, + 513.0, + 505.0, + 516.0, + 439.0, + 492.0, + 423.0, + 461.0, + 426.0, + 379.0, + 378.0, + 404.0, + 326.0, + 340.0, + 322.0, + 310.0, + 265.0, + 282.0, + 296.0, + 257.0, + 239.0, + 263.0, + 240.0, + 252.0, + 235.0, + 201.0, + 3097.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11.0, + 175.0, + 45387.0, + 21013.0, + 20571.0, + 52248.0, + 86357.0, + 200552.0, + 988591.0, + 170562.0, + 91860.0, + 75012.0, + 67953.0, + 68602.0, + 72532.0, + 98627.0, + 86425.0, + 95507.0, + 97154.0, + 127775.0, + 153715.0, + 188343.0, + 241420.0, + 136441.0, + 138408.0, + 146978.0, + 153866.0, + 1041787.0, + 282913.0, + 220914.0, + 267186.0, + 248781.0, + 215239.0, + 204045.0, + 457120.0, + 214507.0, + 213450.0, + 227571.0, + 241979.0, + 269813.0, + 268828.0, + 227449.0, + 408059.0, + 494766.0, + 354081.0, + 284462.0, + 282371.0, + 274202.0, + 275935.0, + 290720.0, + 303487.0, + 284176.0, + 270228.0, + 264100.0, + 269776.0, + 278434.0, + 293158.0, + 255791.0, + 242003.0, + 249054.0, + 257254.0, + 255500.0, + 261098.0, + 272564.0, + 260316.0, + 254553.0, + 245314.0, + 256286.0, + 246691.0, + 228622.0, + 223524.0, + 217560.0, + 210556.0, + 206103.0, + 196633.0, + 191631.0, + 182750.0, + 175021.0, + 172935.0, + 161739.0, + 158412.0, + 150868.0, + 146602.0, + 143844.0, + 144819.0, + 140496.0, + 133714.0, + 128403.0, + 122689.0, + 119151.0, + 111981.0, + 107535.0, + 102926.0, + 97544.0, + 92260.0, + 87042.0, + 84964.0, + 83507.0, + 79612.0, + 75632.0, + 72246.0, + 69492.0, + 66902.0, + 63254.0, + 59147.0, + 56908.0, + 54757.0, + 52012.0, + 48735.0, + 46247.0, + 44159.0, + 41955.0, + 39570.0, + 37765.0, + 36178.0, + 33953.0, + 32427.0, + 30541.0, + 28708.0, + 27877.0, + 26290.0, + 24433.0, + 23302.0, + 21706.0, + 20555.0, + 20171.0, + 18713.0, + 17268.0, + 16429.0, + 15270.0, + 14249.0, + 13488.0, + 175097.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 300.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 33.0, + 47.0, + 3.0, + 3.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 50.0, + 44.0, + 68.0, + 6.0, + 4.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 19.0, + 63.0, + 7.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1124.0, + 76.0, + 297.0, + 3254.0, + 40758.0, + 80092.0, + 76250.0, + 60645.0, + 27973.0, + 24529.0, + 22970.0, + 30149.0, + 28202.0, + 46533.0, + 40365.0, + 18620.0, + 23962.0, + 28121.0, + 45875.0, + 50114.0, + 38623.0, + 46393.0, + 45632.0, + 36820.0, + 44536.0, + 78140.0, + 62161.0, + 78547.0, + 57638.0, + 58251.0, + 46924.0, + 43483.0, + 66380.0, + 128866.0, + 46143.0, + 51153.0, + 52301.0, + 59631.0, + 57875.0, + 57332.0, + 120082.0, + 122695.0, + 67245.0, + 55151.0, + 45010.0, + 41963.0, + 42827.0, + 48562.0, + 47269.0, + 44026.0, + 42070.0, + 43224.0, + 40823.0, + 53173.0, + 58191.0, + 49021.0, + 33035.0, + 31779.0, + 31738.0, + 32200.0, + 35570.0, + 40109.0, + 33167.0, + 30653.0, + 30415.0, + 28105.0, + 29356.0, + 26127.0, + 21490.0, + 20885.0, + 19729.0, + 18474.0, + 17777.0, + 17463.0, + 16993.0, + 17544.0, + 16329.0, + 15356.0, + 15081.0, + 13907.0, + 12708.0, + 12775.0, + 12113.0, + 11537.0, + 10677.0, + 10386.0, + 9943.0, + 8933.0, + 8548.0, + 7820.0, + 7726.0, + 6618.0, + 6119.0, + 5737.0, + 5951.0, + 5470.0, + 5498.0, + 4960.0, + 4781.0, + 4524.0, + 4035.0, + 3824.0, + 3685.0, + 3530.0, + 3472.0, + 3019.0, + 2918.0, + 2562.0, + 2298.0, + 2269.0, + 2056.0, + 1897.0, + 1715.0, + 1605.0, + 1597.0, + 1570.0, + 1445.0, + 1359.0, + 1266.0, + 1126.0, + 1067.0, + 1003.0, + 872.0, + 828.0, + 805.0, + 730.0, + 649.0, + 7144.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8399.0, + 46546.0, + 8.0, + 389.0, + 5425.0, + 30336.0, + 29622.0, + 28002.0, + 29611.0, + 19986.0, + 15466.0, + 15390.0, + 22745.0, + 17779.0, + 32904.0, + 17119.0, + 20212.0, + 20836.0, + 22660.0, + 28141.0, + 38271.0, + 28510.0, + 38039.0, + 31054.0, + 34592.0, + 40661.0, + 48128.0, + 43634.0, + 65803.0, + 44329.0, + 41502.0, + 36074.0, + 34559.0, + 51239.0, + 37177.0, + 38184.0, + 38390.0, + 43156.0, + 45778.0, + 43997.0, + 58864.0, + 82433.0, + 65815.0, + 116072.0, + 74537.0, + 44209.0, + 45129.0, + 48016.0, + 51903.0, + 52173.0, + 48924.0, + 45634.0, + 42362.0, + 42537.0, + 48688.0, + 45902.0, + 39754.0, + 37209.0, + 36327.0, + 35120.0, + 34539.0, + 33646.0, + 38063.0, + 34832.0, + 29942.0, + 28683.0, + 28350.0, + 26076.0, + 25350.0, + 26963.0, + 26593.0, + 23845.0, + 23348.0, + 22600.0, + 22629.0, + 23365.0, + 21647.0, + 20469.0, + 19543.0, + 19542.0, + 17657.0, + 16844.0, + 15862.0, + 14535.0, + 14108.0, + 13816.0, + 13011.0, + 12613.0, + 11553.0, + 11124.0, + 10466.0, + 9926.0, + 9422.0, + 9092.0, + 8399.0, + 8396.0, + 7928.0, + 7682.0, + 7273.0, + 7076.0, + 6746.0, + 6494.0, + 6029.0, + 5631.0, + 5307.0, + 5098.0, + 4912.0, + 4659.0, + 4339.0, + 3883.0, + 3799.0, + 3614.0, + 3339.0, + 3173.0, + 2829.0, + 2813.0, + 2766.0, + 2482.0, + 2357.0, + 2162.0, + 1895.0, + 1811.0, + 1880.0, + 1828.0, + 1665.0, + 1733.0, + 1589.0, + 1412.0, + 16275.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 32.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 158.0, + 169.0, + 56.0, + 112.0, + 28.0, + 26.0, + 23.0, + 29.0, + 63.0, + 52.0, + 31.0, + 44.0, + 69.0, + 86.0, + 78.0, + 123.0, + 137.0, + 190.0, + 87.0, + 55.0, + 37.0, + 104.0, + 44.0, + 33.0, + 34.0, + 40.0, + 64.0, + 39.0, + 32.0, + 21.0, + 39.0, + 27.0, + 27.0, + 10.0, + 18.0, + 25.0, + 75.0, + 82.0, + 177.0, + 67.0, + 183.0, + 72.0, + 58.0, + 70.0, + 85.0, + 79.0, + 79.0, + 72.0, + 65.0, + 36.0, + 23.0, + 38.0, + 29.0, + 79.0, + 23.0, + 46.0, + 14.0, + 16.0, + 29.0, + 14.0, + 41.0, + 76.0, + 74.0, + 31.0, + 21.0, + 18.0, + 8.0, + 13.0, + 6.0, + 9.0, + 9.0, + 6.0, + 2.0, + 0.0, + 3.0, + 10.0, + 19.0, + 8.0, + 11.0, + 7.0, + 1.0, + 1.0, + 2.0, + 4.0, + 4.0, + 6.0, + 3.0, + 1.0, + 2.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 2.0, + 4.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 24.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 11.0, + 224.0, + 9168.0, + 643.0, + 1169.0, + 6243.0, + 6041.0, + 2430.0, + 3420.0, + 2535.0, + 2916.0, + 2644.0, + 2793.0, + 2623.0, + 3058.0, + 5370.0, + 3951.0, + 4237.0, + 4773.0, + 5206.0, + 5414.0, + 6590.0, + 4804.0, + 5194.0, + 5873.0, + 9633.0, + 13208.0, + 12857.0, + 9901.0, + 8705.0, + 9657.0, + 9582.0, + 7994.0, + 7530.0, + 41131.0, + 8906.0, + 8619.0, + 11573.0, + 10279.0, + 13559.0, + 12716.0, + 12375.0, + 13739.0, + 9244.0, + 7586.0, + 6965.0, + 6739.0, + 6458.0, + 7075.0, + 7295.0, + 6821.0, + 6111.0, + 6441.0, + 5714.0, + 6763.0, + 7676.0, + 11415.0, + 6509.0, + 6798.0, + 6099.0, + 6381.0, + 6984.0, + 5645.0, + 5702.0, + 4869.0, + 4939.0, + 5855.0, + 4973.0, + 4963.0, + 3038.0, + 3014.0, + 2991.0, + 2901.0, + 3013.0, + 2706.0, + 3685.0, + 3024.0, + 2579.0, + 2350.0, + 2407.0, + 2238.0, + 2024.0, + 1945.0, + 1734.0, + 1710.0, + 1576.0, + 1539.0, + 1636.0, + 1435.0, + 1279.0, + 1183.0, + 1218.0, + 1193.0, + 1077.0, + 787.0, + 894.0, + 942.0, + 921.0, + 1018.0, + 648.0, + 610.0, + 517.0, + 524.0, + 505.0, + 522.0, + 455.0, + 460.0, + 511.0, + 443.0, + 334.0, + 300.0, + 323.0, + 293.0, + 297.0, + 297.0, + 278.0, + 248.0, + 199.0, + 420.0, + 228.0, + 213.0, + 193.0, + 202.0, + 164.0, + 127.0, + 1457.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 164695.0, + 16777216.0, + 463750.0, + 269926.0, + 984411.0, + 2321856.0, + 5505207.0, + 15333710.0, + 16777216.0, + 16777216.0, + 7987839.0, + 6970926.0, + 5663499.0, + 7522175.0, + 16777216.0, + 5919363.0, + 4655171.0, + 5113476.0, + 7296047.0, + 11456336.0, + 16777216.0, + 16777216.0, + 12691501.0, + 13610250.0, + 14389669.0, + 15260072.0, + 16777216.0, + 15226327.0, + 16213852.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 14114315.0, + 13576543.0, + 14028729.0, + 15199088.0, + 15790142.0, + 14593446.0, + 14045283.0, + 16353889.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16208916.0, + 15544209.0, + 15467945.0, + 16777216.0, + 16212860.0, + 15692384.0, + 15513897.0, + 15109527.0, + 15053017.0, + 15869787.0, + 14686720.0, + 13383169.0, + 12664302.0, + 12459571.0, + 12607629.0, + 12845323.0, + 13233097.0, + 12971842.0, + 12339075.0, + 12001768.0, + 11365966.0, + 11292596.0, + 10889530.0, + 10321392.0, + 10099526.0, + 9891067.0, + 9629487.0, + 9376259.0, + 9084947.0, + 8763645.0, + 8433682.0, + 8135221.0, + 7780167.0, + 7391938.0, + 7000561.0, + 6721633.0, + 6483304.0, + 6359207.0, + 6267304.0, + 6015309.0, + 5777366.0, + 5482417.0, + 5233453.0, + 5004797.0, + 4799408.0, + 4581743.0, + 4356210.0, + 4144982.0, + 3940128.0, + 3751227.0, + 3594079.0, + 3436460.0, + 3244240.0, + 3050931.0, + 2891077.0, + 2742594.0, + 2606788.0, + 2481904.0, + 2366222.0, + 2250384.0, + 2133243.0, + 2019743.0, + 1907011.0, + 1800499.0, + 1700667.0, + 1598045.0, + 1513472.0, + 1428515.0, + 1345334.0, + 1261031.0, + 1190093.0, + 1122136.0, + 1061314.0, + 998739.0, + 932694.0, + 875015.0, + 812623.0, + 765150.0, + 718171.0, + 681071.0, + 639460.0, + 598854.0, + 561570.0, + 525691.0, + 491029.0, + 459811.0, + 431961.0, + 402432.0, + 5126107.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 15.0, + 288.0, + 512.0, + 256.0, + 367.0, + 403.0, + 1988.0, + 3239.0, + 492.0, + 467.0, + 506.0, + 356.0, + 443.0, + 615.0, + 540.0, + 569.0, + 1068.0, + 758.0, + 767.0, + 832.0, + 997.0, + 940.0, + 1011.0, + 1045.0, + 1163.0, + 1196.0, + 1165.0, + 2598.0, + 1371.0, + 1064.0, + 1287.0, + 1423.0, + 1411.0, + 990.0, + 981.0, + 3520.0, + 1099.0, + 1201.0, + 1187.0, + 1297.0, + 1621.0, + 1202.0, + 1340.0, + 1584.0, + 1644.0, + 902.0, + 830.0, + 804.0, + 760.0, + 992.0, + 1036.0, + 903.0, + 905.0, + 870.0, + 947.0, + 950.0, + 932.0, + 913.0, + 944.0, + 865.0, + 749.0, + 821.0, + 934.0, + 1118.0, + 1500.0, + 1075.0, + 1198.0, + 1010.0, + 683.0, + 654.0, + 696.0, + 680.0, + 691.0, + 710.0, + 542.0, + 561.0, + 629.0, + 689.0, + 644.0, + 632.0, + 547.0, + 583.0, + 537.0, + 374.0, + 357.0, + 441.0, + 447.0, + 377.0, + 311.0, + 330.0, + 266.0, + 202.0, + 211.0, + 157.0, + 147.0, + 156.0, + 144.0, + 175.0, + 209.0, + 137.0, + 141.0, + 137.0, + 150.0, + 117.0, + 109.0, + 111.0, + 99.0, + 78.0, + 67.0, + 75.0, + 67.0, + 41.0, + 39.0, + 50.0, + 38.0, + 43.0, + 29.0, + 14.0, + 27.0, + 20.0, + 24.0, + 16.0, + 16.0, + 17.0, + 19.0, + 199.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12.0, + 6316207.0, + 17854.0, + 0.0, + 39.0, + 4157.0, + 12941.0, + 20960.0, + 58625.0, + 143803.0, + 331407.0, + 16777216.0, + 4928937.0, + 498527.0, + 877091.0, + 2161806.0, + 4666206.0, + 10031375.0, + 7934825.0, + 4501448.0, + 2964288.0, + 2704275.0, + 3821821.0, + 5416151.0, + 11036569.0, + 3464111.0, + 2358763.0, + 2793141.0, + 3718034.0, + 6303283.0, + 6295909.0, + 6123465.0, + 5415848.0, + 5191240.0, + 5186973.0, + 5287839.0, + 10805260.0, + 5983254.0, + 5662125.0, + 5674549.0, + 5515592.0, + 5030018.0, + 5302668.0, + 6603380.0, + 4866633.0, + 4999856.0, + 5274731.0, + 5282280.0, + 5303203.0, + 4479897.0, + 4720169.0, + 5703319.0, + 5920716.0, + 4679480.0, + 4367448.0, + 4335037.0, + 3927628.0, + 3906314.0, + 4113206.0, + 4063221.0, + 3638004.0, + 3530701.0, + 3433395.0, + 3436778.0, + 3583493.0, + 3371855.0, + 2874752.0, + 2585772.0, + 2527891.0, + 2497016.0, + 2474751.0, + 2658557.0, + 2570384.0, + 2386182.0, + 2238568.0, + 2054482.0, + 1966518.0, + 1861307.0, + 1708633.0, + 1623968.0, + 1558288.0, + 1484360.0, + 1426186.0, + 1332893.0, + 1280981.0, + 1235780.0, + 1181167.0, + 1087899.0, + 995454.0, + 923796.0, + 865272.0, + 805294.0, + 790517.0, + 769846.0, + 716116.0, + 665866.0, + 637731.0, + 589802.0, + 551343.0, + 518888.0, + 476951.0, + 443811.0, + 411877.0, + 376820.0, + 359402.0, + 340992.0, + 324985.0, + 304392.0, + 273253.0, + 255472.0, + 236365.0, + 220843.0, + 208224.0, + 196810.0, + 184426.0, + 172017.0, + 161756.0, + 147545.0, + 136394.0, + 127072.0, + 118518.0, + 108305.0, + 101154.0, + 93188.0, + 85646.0, + 79458.0, + 74471.0, + 69785.0, + 64638.0, + 59235.0, + 54803.0, + 50793.0, + 45897.0, + 43032.0, + 40936.0, + 39957.0, + 36747.0, + 32805.0, + 29904.0, + 26777.0, + 299233.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 917743.0, + 30831.0, + 33.0, + 0.0, + 326.0, + 7476.0, + 35278.0, + 87306.0, + 175277.0, + 346112.0, + 2602607.0, + 11355709.0, + 1538783.0, + 3124228.0, + 4580896.0, + 5399762.0, + 7495010.0, + 5937816.0, + 6465830.0, + 2998078.0, + 2575869.0, + 2661114.0, + 5918245.0, + 4443072.0, + 5051868.0, + 2723929.0, + 3379163.0, + 3925961.0, + 4556521.0, + 5878930.0, + 6142001.0, + 4985084.0, + 5161494.0, + 4978967.0, + 4972712.0, + 7335126.0, + 8481313.0, + 5983247.0, + 6298232.0, + 5882001.0, + 5680965.0, + 5097013.0, + 4843327.0, + 5700897.0, + 4323869.0, + 4787907.0, + 4973023.0, + 5237023.0, + 5206890.0, + 4733937.0, + 5999164.0, + 6079368.0, + 5890081.0, + 5690517.0, + 5240201.0, + 4391380.0, + 4319901.0, + 4547537.0, + 4768660.0, + 4507687.0, + 4460715.0, + 4295132.0, + 4148067.0, + 4125078.0, + 4230935.0, + 3645690.0, + 3356538.0, + 3217726.0, + 3179740.0, + 3211669.0, + 3204661.0, + 3310373.0, + 3252372.0, + 3160206.0, + 2961953.0, + 2864973.0, + 2698092.0, + 2550186.0, + 2420557.0, + 2366560.0, + 2287596.0, + 2254506.0, + 2148306.0, + 2031856.0, + 1934240.0, + 1865455.0, + 1759138.0, + 1627941.0, + 1548125.0, + 1444160.0, + 1376935.0, + 1331998.0, + 1324054.0, + 1284597.0, + 1202913.0, + 1141741.0, + 1076341.0, + 1018165.0, + 965883.0, + 914972.0, + 858813.0, + 800859.0, + 742718.0, + 698833.0, + 668222.0, + 645629.0, + 615383.0, + 569813.0, + 529578.0, + 498142.0, + 468674.0, + 446318.0, + 423099.0, + 401830.0, + 381871.0, + 359133.0, + 338351.0, + 318575.0, + 298227.0, + 275594.0, + 263288.0, + 248693.0, + 232365.0, + 217334.0, + 203430.0, + 189018.0, + 177903.0, + 169473.0, + 159401.0, + 145345.0, + 135923.0, + 127928.0, + 119769.0, + 112945.0, + 106856.0, + 99795.0, + 92578.0, + 87111.0, + 80382.0, + 73118.0, + 905858.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 704.0, + 5789.0, + 5148.0, + 7422.0, + 12400.0, + 2775.0, + 11415.0, + 14753.0, + 13410.0, + 12594.0, + 8050.0, + 5306.0, + 6027.0, + 6427.0, + 12065.0, + 11286.0, + 5670.0, + 6064.0, + 4913.0, + 5346.0, + 5859.0, + 8185.0, + 9001.0, + 6816.0, + 7923.0, + 5974.0, + 5273.0, + 5807.0, + 11785.0, + 14515.0, + 8772.0, + 7270.0, + 6826.0, + 5779.0, + 5547.0, + 5194.0, + 11728.0, + 4635.0, + 5778.0, + 7279.0, + 5697.0, + 5256.0, + 6501.0, + 5874.0, + 4093.0, + 3745.0, + 3337.0, + 3781.0, + 3301.0, + 3147.0, + 3478.0, + 3247.0, + 3482.0, + 3067.0, + 2261.0, + 2270.0, + 2050.0, + 1920.0, + 2410.0, + 1595.0, + 1206.0, + 1167.0, + 1330.0, + 1643.0, + 1958.0, + 1123.0, + 1174.0, + 825.0, + 825.0, + 1060.0, + 869.0, + 794.0, + 578.0, + 658.0, + 519.0, + 466.0, + 628.0, + 523.0, + 459.0, + 393.0, + 362.0, + 226.0, + 253.0, + 234.0, + 229.0, + 237.0, + 259.0, + 266.0, + 235.0, + 150.0, + 161.0, + 140.0, + 146.0, + 211.0, + 244.0, + 178.0, + 153.0, + 144.0, + 147.0, + 133.0, + 121.0, + 71.0, + 83.0, + 71.0, + 70.0, + 54.0, + 47.0, + 51.0, + 48.0, + 49.0, + 36.0, + 32.0, + 28.0, + 31.0, + 20.0, + 34.0, + 25.0, + 21.0, + 29.0, + 20.0, + 16.0, + 28.0, + 26.0, + 39.0, + 23.0, + 19.0, + 17.0, + 12.0, + 16.0, + 95.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2228.0, + 0.0, + 1.0, + 0.0, + 0.0, + 888.0, + 1416.0, + 1680.0, + 12580.0, + 384818.0, + 1929285.0, + 1374626.0, + 522384.0, + 1256168.0, + 1028657.0, + 2136552.0, + 943007.0, + 982475.0, + 522778.0, + 458580.0, + 478335.0, + 598666.0, + 1558618.0, + 970886.0, + 714126.0, + 428327.0, + 532814.0, + 590839.0, + 819780.0, + 1025616.0, + 1060235.0, + 1127459.0, + 1046900.0, + 1169909.0, + 1255099.0, + 2284672.0, + 1630015.0, + 1096110.0, + 964006.0, + 939297.0, + 933921.0, + 923646.0, + 1065615.0, + 1145897.0, + 918150.0, + 913702.0, + 976673.0, + 862772.0, + 890838.0, + 885091.0, + 934887.0, + 1079815.0, + 1134025.0, + 808744.0, + 845238.0, + 767957.0, + 723710.0, + 778328.0, + 817912.0, + 677405.0, + 650508.0, + 627649.0, + 594963.0, + 644296.0, + 673395.0, + 592174.0, + 500358.0, + 491830.0, + 510411.0, + 520866.0, + 553378.0, + 520423.0, + 464197.0, + 438474.0, + 428083.0, + 392511.0, + 392329.0, + 370101.0, + 310393.0, + 294301.0, + 289682.0, + 267812.0, + 257736.0, + 249314.0, + 249364.0, + 235084.0, + 230695.0, + 206736.0, + 193008.0, + 178940.0, + 173517.0, + 160823.0, + 157829.0, + 153239.0, + 142446.0, + 127343.0, + 119132.0, + 109335.0, + 103633.0, + 92882.0, + 86984.0, + 81869.0, + 75909.0, + 68999.0, + 67772.0, + 63766.0, + 62862.0, + 55704.0, + 49846.0, + 46685.0, + 44334.0, + 41536.0, + 37663.0, + 34812.0, + 33391.0, + 32326.0, + 29655.0, + 26760.0, + 24807.0, + 22312.0, + 20818.0, + 19733.0, + 18342.0, + 15925.0, + 15494.0, + 13737.0, + 13218.0, + 12165.0, + 10805.0, + 10425.0, + 8936.0, + 8298.0, + 8064.0, + 7271.0, + 7470.0, + 7376.0, + 67681.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 6.0, + 4.0, + 9.0, + 14.0, + 13.0, + 7.0, + 8.0, + 4.0, + 6.0, + 22.0, + 11.0, + 8.0, + 9.0, + 15.0, + 21.0, + 18.0, + 32.0, + 40.0, + 310.0, + 1320.0, + 150.0, + 34.0, + 38.0, + 56.0, + 32.0, + 45.0, + 46.0, + 26.0, + 33.0, + 23.0, + 12.0, + 21.0, + 16.0, + 37.0, + 18.0, + 12.0, + 29.0, + 8.0, + 3.0, + 2.0, + 5.0, + 5.0, + 5.0, + 4.0, + 2.0, + 14.0, + 1.0, + 1.0, + 8.0, + 2.0, + 5.0, + 31.0, + 1.0, + 1.0, + 5.0, + 2.0, + 5.0, + 6.0, + 5.0, + 4.0, + 1.0, + 0.0, + 2.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 16.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.0, + 0.0, + 3.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8.0, + 16.0, + 309.0, + 1077.0, + 114.0, + 337.0, + 275.0, + 151.0, + 186.0, + 203.0, + 165.0, + 65.0, + 87.0, + 102.0, + 177.0, + 79.0, + 77.0, + 49.0, + 52.0, + 61.0, + 62.0, + 67.0, + 94.0, + 134.0, + 306.0, + 1138.0, + 157.0, + 154.0, + 215.0, + 210.0, + 163.0, + 189.0, + 192.0, + 962.0, + 333.0, + 293.0, + 404.0, + 449.0, + 245.0, + 173.0, + 162.0, + 532.0, + 315.0, + 235.0, + 245.0, + 184.0, + 187.0, + 182.0, + 126.0, + 131.0, + 137.0, + 107.0, + 127.0, + 98.0, + 117.0, + 161.0, + 97.0, + 99.0, + 75.0, + 85.0, + 60.0, + 43.0, + 28.0, + 30.0, + 23.0, + 45.0, + 34.0, + 61.0, + 41.0, + 47.0, + 62.0, + 39.0, + 37.0, + 28.0, + 53.0, + 26.0, + 20.0, + 24.0, + 27.0, + 34.0, + 29.0, + 13.0, + 18.0, + 29.0, + 15.0, + 18.0, + 17.0, + 10.0, + 11.0, + 16.0, + 11.0, + 20.0, + 21.0, + 21.0, + 19.0, + 15.0, + 14.0, + 5.0, + 5.0, + 5.0, + 3.0, + 11.0, + 12.0, + 4.0, + 2.0, + 3.0, + 5.0, + 4.0, + 2.0, + 3.0, + 0.0, + 1.0, + 2.0, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 8.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 16.0, + 14.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12.0, + 392.0, + 385.0, + 789.0, + 697.0, + 324.0, + 297.0, + 260.0, + 222.0, + 242.0, + 373.0, + 201.0, + 167.0, + 197.0, + 216.0, + 295.0, + 198.0, + 188.0, + 159.0, + 195.0, + 352.0, + 219.0, + 284.0, + 250.0, + 205.0, + 470.0, + 1067.0, + 544.0, + 182.0, + 363.0, + 508.0, + 526.0, + 456.0, + 541.0, + 505.0, + 370.0, + 491.0, + 1177.0, + 270.0, + 271.0, + 298.0, + 424.0, + 671.0, + 382.0, + 320.0, + 283.0, + 266.0, + 283.0, + 250.0, + 231.0, + 208.0, + 188.0, + 184.0, + 196.0, + 205.0, + 256.0, + 271.0, + 261.0, + 210.0, + 335.0, + 255.0, + 182.0, + 132.0, + 189.0, + 129.0, + 100.0, + 90.0, + 162.0, + 122.0, + 56.0, + 30.0, + 33.0, + 40.0, + 35.0, + 53.0, + 56.0, + 95.0, + 91.0, + 138.0, + 85.0, + 91.0, + 54.0, + 77.0, + 41.0, + 37.0, + 43.0, + 37.0, + 30.0, + 20.0, + 20.0, + 13.0, + 22.0, + 30.0, + 23.0, + 20.0, + 18.0, + 15.0, + 18.0, + 14.0, + 19.0, + 22.0, + 15.0, + 11.0, + 10.0, + 8.0, + 15.0, + 18.0, + 9.0, + 8.0, + 9.0, + 6.0, + 12.0, + 3.0, + 3.0, + 5.0, + 3.0, + 3.0, + 1.0, + 1.0, + 3.0, + 1.0, + 7.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 15.0, + 106.0, + 55.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 4.0, + 2.0, + 2.0, + 2.0, + 9.0, + 12.0, + 5.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 3.0, + 31.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 0.0, + 1.0, + 1.0, + 4.0, + 0.0, + 3.0, + 4.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 4.0, + 6.0, + 0.0, + 1.0, + 1.0, + 2.0, + 4.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1922.0, + 216168.0, + 1604958.0, + 25745.0, + 106.0, + 0.0, + 0.0, + 0.0, + 0.0, + 33058.0, + 750099.0, + 1146697.0, + 1961629.0, + 269623.0, + 109654.0, + 298688.0, + 532408.0, + 809051.0, + 486098.0, + 331575.0, + 233794.0, + 199097.0, + 217874.0, + 493979.0, + 785383.0, + 559174.0, + 245271.0, + 220183.0, + 302222.0, + 500256.0, + 438328.0, + 445826.0, + 371231.0, + 406495.0, + 459239.0, + 400443.0, + 432589.0, + 366745.0, + 276633.0, + 275773.0, + 215944.0, + 199879.0, + 199533.0, + 196443.0, + 309470.0, + 232036.0, + 201354.0, + 203704.0, + 185507.0, + 180180.0, + 159822.0, + 191030.0, + 251789.0, + 274911.0, + 187484.0, + 150387.0, + 123167.0, + 108748.0, + 122253.0, + 114782.0, + 98092.0, + 89659.0, + 83170.0, + 78074.0, + 77266.0, + 90588.0, + 83349.0, + 67575.0, + 64501.0, + 59660.0, + 53604.0, + 51039.0, + 52713.0, + 55402.0, + 50298.0, + 44493.0, + 39477.0, + 35685.0, + 34874.0, + 27203.0, + 24126.0, + 27939.0, + 20812.0, + 19135.0, + 18471.0, + 19458.0, + 19142.0, + 18653.0, + 16917.0, + 14422.0, + 14283.0, + 11646.0, + 10826.0, + 10417.0, + 10385.0, + 10127.0, + 9356.0, + 8309.0, + 7966.0, + 6963.0, + 6097.0, + 5306.0, + 5240.0, + 4289.0, + 3724.0, + 3784.0, + 3418.0, + 3577.0, + 3521.0, + 3146.0, + 2781.0, + 2349.0, + 2117.0, + 1997.0, + 2011.0, + 2222.0, + 2063.0, + 1704.0, + 1632.0, + 1319.0, + 1336.0, + 1315.0, + 1382.0, + 1089.0, + 833.0, + 744.0, + 747.0, + 754.0, + 598.0, + 609.0, + 659.0, + 636.0, + 433.0, + 427.0, + 363.0, + 316.0, + 321.0, + 293.0, + 3112.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1566.0, + 660561.0, + 284576.0, + 30077.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 181862.0, + 6270988.0, + 488640.0, + 761876.0, + 1438275.0, + 1000768.0, + 994492.0, + 1244000.0, + 813544.0, + 566745.0, + 464519.0, + 461372.0, + 481487.0, + 858337.0, + 964252.0, + 577708.0, + 423111.0, + 441698.0, + 576619.0, + 1059583.0, + 919272.0, + 860701.0, + 664399.0, + 750938.0, + 937767.0, + 999002.0, + 1006642.0, + 789114.0, + 690390.0, + 688343.0, + 651159.0, + 564866.0, + 537825.0, + 561048.0, + 884321.0, + 517418.0, + 540475.0, + 564447.0, + 597654.0, + 584791.0, + 551467.0, + 760879.0, + 952148.0, + 712293.0, + 787560.0, + 497565.0, + 380783.0, + 379939.0, + 409608.0, + 413784.0, + 409035.0, + 369970.0, + 343029.0, + 329323.0, + 336254.0, + 411397.0, + 336341.0, + 256749.0, + 228833.0, + 226324.0, + 224281.0, + 227237.0, + 241797.0, + 246814.0, + 216282.0, + 201295.0, + 184372.0, + 180717.0, + 156659.0, + 133568.0, + 130108.0, + 117117.0, + 110728.0, + 100062.0, + 96883.0, + 100361.0, + 96992.0, + 91327.0, + 77051.0, + 73227.0, + 65242.0, + 58901.0, + 54987.0, + 55000.0, + 52492.0, + 49843.0, + 47643.0, + 44074.0, + 41620.0, + 39022.0, + 35083.0, + 31336.0, + 28711.0, + 26436.0, + 24139.0, + 22148.0, + 20794.0, + 21212.0, + 20694.0, + 18554.0, + 17637.0, + 15612.0, + 14528.0, + 14137.0, + 13071.0, + 12424.0, + 11535.0, + 9919.0, + 8974.0, + 7990.0, + 7277.0, + 6768.0, + 7179.0, + 6338.0, + 6049.0, + 5200.0, + 4462.0, + 4277.0, + 4681.0, + 4311.0, + 3967.0, + 3710.0, + 3223.0, + 2794.0, + 2593.0, + 2541.0, + 2553.0, + 2106.0, + 20259.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 83.0, + 9306.0, + 1738.0, + 5.0, + 0.0, + 0.0, + 0.0, + 4.0, + 25.0, + 79.0, + 669.0, + 1343.0, + 1937.0, + 1287.0, + 699.0, + 517.0, + 535.0, + 635.0, + 388.0, + 376.0, + 703.0, + 631.0, + 603.0, + 846.0, + 906.0, + 609.0, + 347.0, + 392.0, + 361.0, + 427.0, + 488.0, + 397.0, + 339.0, + 317.0, + 279.0, + 335.0, + 465.0, + 481.0, + 331.0, + 299.0, + 290.0, + 278.0, + 205.0, + 178.0, + 695.0, + 245.0, + 202.0, + 281.0, + 182.0, + 238.0, + 239.0, + 271.0, + 216.0, + 128.0, + 102.0, + 120.0, + 61.0, + 69.0, + 153.0, + 79.0, + 59.0, + 89.0, + 74.0, + 71.0, + 43.0, + 30.0, + 62.0, + 42.0, + 82.0, + 71.0, + 83.0, + 56.0, + 63.0, + 48.0, + 33.0, + 56.0, + 41.0, + 27.0, + 41.0, + 50.0, + 39.0, + 41.0, + 40.0, + 40.0, + 24.0, + 6.0, + 24.0, + 17.0, + 9.0, + 17.0, + 14.0, + 14.0, + 30.0, + 13.0, + 10.0, + 10.0, + 11.0, + 10.0, + 10.0, + 7.0, + 10.0, + 13.0, + 22.0, + 4.0, + 8.0, + 6.0, + 9.0, + 9.0, + 1.0, + 5.0, + 8.0, + 3.0, + 4.0, + 0.0, + 1.0, + 4.0, + 3.0, + 0.0, + 7.0, + 6.0, + 6.0, + 1.0, + 3.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7424.0, + 960134.0, + 94427.0, + 1088.0, + 4719.0, + 334.0, + 120.0, + 119.0, + 359.0, + 17624.0, + 696845.0, + 1100211.0, + 893269.0, + 63645.0, + 131994.0, + 196342.0, + 154238.0, + 168727.0, + 202975.0, + 142900.0, + 112231.0, + 123012.0, + 149015.0, + 544941.0, + 276946.0, + 147268.0, + 84145.0, + 80658.0, + 149953.0, + 147428.0, + 126780.0, + 94910.0, + 92247.0, + 133164.0, + 148248.0, + 211423.0, + 188447.0, + 116783.0, + 112331.0, + 102551.0, + 74823.0, + 79117.0, + 83822.0, + 123988.0, + 219718.0, + 86419.0, + 92131.0, + 80150.0, + 75908.0, + 68217.0, + 65163.0, + 79496.0, + 97426.0, + 72559.0, + 54875.0, + 46672.0, + 43224.0, + 45342.0, + 43648.0, + 39314.0, + 34751.0, + 35560.0, + 39882.0, + 32634.0, + 42350.0, + 38678.0, + 34601.0, + 26034.0, + 24491.0, + 27211.0, + 26353.0, + 23147.0, + 19684.0, + 18821.0, + 16701.0, + 15919.0, + 14348.0, + 15342.0, + 12621.0, + 9643.0, + 9240.0, + 8022.0, + 7369.0, + 7345.0, + 7290.0, + 8112.0, + 8157.0, + 6769.0, + 6396.0, + 5990.0, + 5172.0, + 4715.0, + 4452.0, + 4493.0, + 3914.0, + 3441.0, + 2706.0, + 2614.0, + 2612.0, + 1970.0, + 1838.0, + 1684.0, + 1458.0, + 1277.0, + 1458.0, + 1345.0, + 1332.0, + 1611.0, + 1411.0, + 1047.0, + 1031.0, + 1060.0, + 937.0, + 1419.0, + 1127.0, + 529.0, + 498.0, + 433.0, + 401.0, + 384.0, + 349.0, + 312.0, + 311.0, + 278.0, + 256.0, + 322.0, + 183.0, + 155.0, + 161.0, + 144.0, + 170.0, + 271.0, + 259.0, + 168.0, + 787.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 124.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 354569.0, + 1344874.0, + 164270.0, + 1185318.0, + 254807.0, + 244620.0, + 252804.0, + 455442.0, + 264564.0, + 214627.0, + 187827.0, + 197038.0, + 225751.0, + 297060.0, + 652823.0, + 236188.0, + 181138.0, + 208740.0, + 244666.0, + 289169.0, + 355849.0, + 345073.0, + 374054.0, + 468176.0, + 775547.0, + 465460.0, + 622412.0, + 565619.0, + 484713.0, + 436315.0, + 383819.0, + 291328.0, + 268606.0, + 290184.0, + 350395.0, + 271564.0, + 293188.0, + 318337.0, + 334672.0, + 334602.0, + 355083.0, + 465026.0, + 561353.0, + 408718.0, + 508457.0, + 316003.0, + 289270.0, + 304442.0, + 323984.0, + 319935.0, + 333104.0, + 314223.0, + 287982.0, + 268108.0, + 274627.0, + 288774.0, + 229854.0, + 228977.0, + 173093.0, + 179858.0, + 171885.0, + 179904.0, + 189161.0, + 200962.0, + 167173.0, + 162182.0, + 163232.0, + 134415.0, + 121511.0, + 111492.0, + 107598.0, + 103944.0, + 102085.0, + 92089.0, + 90565.0, + 89420.0, + 88071.0, + 83451.0, + 69152.0, + 65623.0, + 60437.0, + 57936.0, + 55913.0, + 55389.0, + 53166.0, + 51105.0, + 47186.0, + 42574.0, + 41481.0, + 38218.0, + 37100.0, + 33161.0, + 31310.0, + 29111.0, + 28476.0, + 27226.0, + 26782.0, + 25137.0, + 24399.0, + 22083.0, + 20997.0, + 19292.0, + 18039.0, + 18432.0, + 16861.0, + 14992.0, + 14448.0, + 13114.0, + 12020.0, + 10762.0, + 10068.0, + 9378.0, + 9207.0, + 8017.0, + 7697.0, + 7055.0, + 6116.0, + 6164.0, + 6161.0, + 5452.0, + 5134.0, + 5080.0, + 5199.0, + 4814.0, + 4461.0, + 4021.0, + 3835.0, + 3601.0, + 39515.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17791.0, + 11302.0, + 15025.0, + 132.0, + 19.0, + 0.0, + 0.0, + 9.0, + 1.0, + 33.0, + 187.0, + 434.0, + 1653.0, + 1213.0, + 704.0, + 765.0, + 1221.0, + 1071.0, + 1024.0, + 815.0, + 852.0, + 864.0, + 867.0, + 943.0, + 855.0, + 2219.0, + 1773.0, + 1097.0, + 990.0, + 669.0, + 1085.0, + 518.0, + 551.0, + 417.0, + 459.0, + 597.0, + 669.0, + 875.0, + 1038.0, + 783.0, + 537.0, + 515.0, + 558.0, + 489.0, + 384.0, + 421.0, + 343.0, + 375.0, + 427.0, + 561.0, + 503.0, + 601.0, + 544.0, + 415.0, + 274.0, + 247.0, + 255.0, + 393.0, + 306.0, + 380.0, + 330.0, + 336.0, + 275.0, + 215.0, + 144.0, + 117.0, + 206.0, + 217.0, + 179.0, + 124.0, + 114.0, + 154.0, + 180.0, + 209.0, + 239.0, + 114.0, + 81.0, + 78.0, + 88.0, + 36.0, + 27.0, + 41.0, + 46.0, + 55.0, + 40.0, + 50.0, + 67.0, + 49.0, + 33.0, + 71.0, + 42.0, + 23.0, + 16.0, + 21.0, + 18.0, + 25.0, + 19.0, + 46.0, + 14.0, + 15.0, + 9.0, + 10.0, + 32.0, + 21.0, + 19.0, + 9.0, + 9.0, + 23.0, + 20.0, + 16.0, + 20.0, + 11.0, + 16.0, + 16.0, + 7.0, + 25.0, + 27.0, + 21.0, + 3.0, + 7.0, + 13.0, + 10.0, + 16.0, + 6.0, + 11.0, + 13.0, + 15.0, + 12.0, + 16.0, + 11.0, + 7.0, + 2.0, + 0.0, + 0.0, + 0.0, + 60.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2304112.0, + 2761.0, + 10802.0, + 68.0, + 51.0, + 36.0, + 24.0, + 16.0, + 3847.0, + 11144.0, + 16116.0, + 80777.0, + 127408.0, + 33529.0, + 82533.0, + 108971.0, + 100935.0, + 102063.0, + 106198.0, + 114017.0, + 115269.0, + 162408.0, + 143522.0, + 137537.0, + 271278.0, + 258060.0, + 96370.0, + 126002.0, + 95350.0, + 105739.0, + 139780.0, + 193465.0, + 133572.0, + 127582.0, + 224828.0, + 346499.0, + 144503.0, + 154290.0, + 154812.0, + 132259.0, + 105570.0, + 102398.0, + 109590.0, + 103541.0, + 154835.0, + 141556.0, + 103281.0, + 112506.0, + 130346.0, + 126545.0, + 113562.0, + 114344.0, + 128385.0, + 140957.0, + 138660.0, + 137983.0, + 79007.0, + 74675.0, + 88699.0, + 95887.0, + 85461.0, + 76979.0, + 66752.0, + 57612.0, + 58258.0, + 75301.0, + 75774.0, + 66577.0, + 49381.0, + 47429.0, + 48003.0, + 49033.0, + 49257.0, + 44605.0, + 44110.0, + 38296.0, + 38107.0, + 35604.0, + 32188.0, + 27288.0, + 25169.0, + 24157.0, + 23599.0, + 20346.0, + 19072.0, + 19490.0, + 19241.0, + 17388.0, + 16545.0, + 15150.0, + 15338.0, + 12503.0, + 13187.0, + 12792.0, + 11292.0, + 10298.0, + 9441.0, + 10117.0, + 7942.0, + 7441.0, + 7251.0, + 6403.0, + 5885.0, + 5538.0, + 4491.0, + 4052.0, + 3920.0, + 4646.0, + 3789.0, + 3273.0, + 3082.0, + 2596.0, + 2565.0, + 2603.0, + 2451.0, + 2211.0, + 1836.0, + 1962.0, + 1583.0, + 1511.0, + 1384.0, + 1114.0, + 1033.0, + 1068.0, + 1080.0, + 1100.0, + 1087.0, + 827.0, + 716.0, + 806.0, + 979.0, + 631.0, + 669.0, + 628.0, + 734.0, + 4656.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17.0, + 79.0, + 111.0, + 245.0, + 51.0, + 37.0, + 61.0, + 30.0, + 51.0, + 165.0, + 121.0, + 99.0, + 101.0, + 48.0, + 35.0, + 35.0, + 10.0, + 14.0, + 13.0, + 40.0, + 6.0, + 4.0, + 2.0, + 8.0, + 0.0, + 3.0, + 2.0, + 4.0, + 7.0, + 9.0, + 37.0, + 45.0, + 23.0, + 15.0, + 21.0, + 25.0, + 6.0, + 8.0, + 11.0, + 21.0, + 30.0, + 19.0, + 14.0, + 13.0, + 14.0, + 11.0, + 1.0, + 6.0, + 12.0, + 15.0, + 12.0, + 11.0, + 8.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 2.0, + 0.0, + 2.0, + 0.0, + 4.0, + 2.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 4.0, + 8.0, + 5.0, + 6.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1968.0, + 23.0, + 565.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 50.0, + 72.0, + 102.0, + 377.0, + 270.0, + 112.0, + 95.0, + 42.0, + 23.0, + 4.0, + 5.0, + 22.0, + 24.0, + 24.0, + 48.0, + 62.0, + 100.0, + 58.0, + 57.0, + 35.0, + 24.0, + 15.0, + 26.0, + 21.0, + 16.0, + 25.0, + 24.0, + 32.0, + 19.0, + 18.0, + 21.0, + 12.0, + 5.0, + 8.0, + 11.0, + 22.0, + 19.0, + 11.0, + 15.0, + 5.0, + 3.0, + 17.0, + 65.0, + 93.0, + 199.0, + 38.0, + 5.0, + 2.0, + 10.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 3.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 3.0, + 3.0, + 0.0, + 2.0, + 0.0, + 2.0, + 1.0, + 5.0, + 1.0, + 2.0, + 4.0, + 2.0, + 0.0, + 2.0, + 0.0, + 1.0, + 3.0, + 1.0, + 1.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1012.0, + 112.0, + 0.0, + 0.0, + 1.0, + 139.0, + 234.0, + 488.0, + 2851.0, + 34865.0, + 104431.0, + 9185.0, + 6524.0, + 4086.0, + 4252.0, + 5162.0, + 4103.0, + 4436.0, + 4395.0, + 4675.0, + 6798.0, + 9798.0, + 16912.0, + 12277.0, + 7100.0, + 6881.0, + 9109.0, + 15514.0, + 15464.0, + 9538.0, + 9986.0, + 11553.0, + 18114.0, + 31011.0, + 19086.0, + 19285.0, + 10256.0, + 9973.0, + 8127.0, + 10413.0, + 7471.0, + 18072.0, + 14417.0, + 14946.0, + 7456.0, + 8112.0, + 6543.0, + 6044.0, + 5985.0, + 6828.0, + 7118.0, + 6810.0, + 7071.0, + 4446.0, + 4200.0, + 4817.0, + 5881.0, + 5053.0, + 5581.0, + 4082.0, + 4088.0, + 3840.0, + 3111.0, + 3979.0, + 4809.0, + 3535.0, + 2953.0, + 2059.0, + 3051.0, + 3224.0, + 3321.0, + 1408.0, + 1424.0, + 1260.0, + 1969.0, + 1298.0, + 2745.0, + 1113.0, + 994.0, + 968.0, + 888.0, + 879.0, + 894.0, + 1134.0, + 692.0, + 980.0, + 735.0, + 667.0, + 514.0, + 400.0, + 372.0, + 329.0, + 499.0, + 366.0, + 250.0, + 347.0, + 364.0, + 274.0, + 156.0, + 123.0, + 129.0, + 78.0, + 160.0, + 113.0, + 138.0, + 166.0, + 109.0, + 88.0, + 71.0, + 65.0, + 108.0, + 54.0, + 112.0, + 54.0, + 61.0, + 67.0, + 48.0, + 45.0, + 35.0, + 27.0, + 33.0, + 29.0, + 49.0, + 248.0, + 18.0, + 20.0, + 15.0, + 8.0, + 22.0, + 172.0 + ] + ] + ], + "radius_of_gyration_histogram": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 12.0, + 23.0, + 87.0, + 164.0, + 253.0, + 477.0, + 714.0, + 1319.0, + 1732.0, + 2386.0, + 3418.0, + 5730.0, + 10060.0, + 18674.0, + 34541.0, + 60106.0, + 97930.0, + 150468.0, + 210344.0, + 278671.0, + 346130.0, + 399669.0, + 436319.0, + 448469.0, + 441026.0, + 413218.0, + 375242.0, + 327566.0, + 282426.0, + 239205.0, + 200625.0, + 167322.0, + 137928.0, + 112325.0, + 92813.0, + 75387.0, + 60475.0, + 47765.0, + 37769.0, + 29783.0, + 23720.0, + 18752.0, + 14492.0, + 10712.0, + 8238.0, + 6185.0, + 4479.0, + 3147.0, + 2288.0, + 1599.0, + 1212.0, + 747.0, + 503.0, + 360.0, + 197.0, + 134.0, + 104.0, + 79.0, + 38.0, + 37.0, + 18.0, + 12.0, + 16.0, + 8.0 + ] +} diff --git a/geom/scaffold_murcko_substituent_lora_r16/distributions.pt b/geom/scaffold_murcko_substituent_lora_r16/distributions.pt new file mode 100644 index 0000000000000000000000000000000000000000..3d8135f29eef5dad8c533d8dc93c560c3c958b99 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/distributions.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40a31704ab58d0349daff35083d43dcf4771ef368fd9c8a8503c94fe844016c5 +size 63581 diff --git a/geom/scaffold_murcko_substituent_lora_r16/edge_tokens.txt b/geom/scaffold_murcko_substituent_lora_r16/edge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..59a3b678b20991c4db5881d2f6b9f0f2fc214357 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/edge_tokens.txt @@ -0,0 +1,4 @@ + +1 +2 +3 diff --git a/geom/scaffold_murcko_substituent_lora_r16/epoch_025.ckpt b/geom/scaffold_murcko_substituent_lora_r16/epoch_025.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..3c04a676c591398cce4c4a3755bfa0f6656bff2e --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/epoch_025.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f3b9d75897c951fdfe437bb10735fb371ea2f5e9ea982471c3c7bd73a160484 +size 638268597 diff --git a/geom/scaffold_murcko_substituent_lora_r16/evaluate.py b/geom/scaffold_murcko_substituent_lora_r16/evaluate.py new file mode 100644 index 0000000000000000000000000000000000000000..e7f9aad6c7fd8ff67aaf2a3b4e2bef95cd1e39f0 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/evaluate.py @@ -0,0 +1,221 @@ +"""Evaluate the released scaffold checkpoint on a fixed panel for three seeds.""" +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import math +import os +from pathlib import Path +import statistics +import time +import types + +import pytorch_lightning as pl +import torch +from omegaconf import OmegaConf +from rdkit import Chem, rdBase + +import morph.config # noqa: F401 +from morph.inference.generate import _build_module, load_checkpoint + + +def sha(path): + with Path(path).open("rb") as f: + return hashlib.file_digest(f, "sha256").hexdigest() + + +def clean(value): + if isinstance(value, torch.Tensor): + return clean(value.item()) + if isinstance(value, float) and not math.isfinite(value): + return None + if isinstance(value, dict): + return {key: clean(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [clean(item) for item in value] + return value + + +def write_json(path, value): + path.write_text(json.dumps(clean(value), indent=2, allow_nan=False) + "\n") + + +def configuration(bundle, data_root, seed, output): + os.environ["MORPH_SCAFFOLD_BUNDLE"] = str(bundle) + os.environ["MORPH_GEOM_ROOT"] = str(data_root) + os.environ.setdefault("PROJECT_ROOT", str(bundle / "runtime")) + os.environ.setdefault("HYDRA_JOBS", str(output)) + cfg = OmegaConf.load(bundle / "config.yaml") + OmegaConf.set_struct(cfg, False) + cfg.seed = seed + cfg.callbacks = {} + cfg.logging.enabled = False + cfg.trainer.do_train = False + cfg.trainer.do_validate = True + cfg.trainer.do_predict = False + cfg.trainer.trainer.devices = 1 + cfg.trainer.trainer.strategy = "auto" + cfg.trainer.trainer.enable_checkpointing = False + cfg.trainer.trainer.num_val_samples = 4096 + cfg.trainer.trainer.limit_val_batches = 128 + cfg.trainer.trainer.default_root_dir = str(output) + cfg.trainer.trainer.pop("max_time", None) + cfg.data.datamodule.num_workers.train = 0 + cfg.data.datamodule.num_workers.val = 0 + cfg.data.datamodule.num_workers.test = 0 + return cfg + + +def preflight(args): + torch.set_num_threads(1) + bundle = args.bundle.resolve() + metadata = json.loads((bundle / "metadata.json").read_text()) + checkpoint = bundle / "epoch_025.ckpt" + assert sha(checkpoint) == metadata["checkpoint_sha256"] + raw = torch.load(checkpoint, map_location="cpu", weights_only=False, mmap=True) + assert raw["epoch"] == 24 and raw["global_step"] == 199100 + assert any("lora_" in key for key in raw["state_dict"]) + cfg = configuration(bundle, args.data_root.resolve(), 42, args.output.resolve()) + assert cfg.model.lora.rank == 16 and cfg.data.insertion_assignment == "substituent" + assert cfg.model.module.pretrained_ckpt is None and cfg.model.module.use_ema_for_eval + assert cfg.model.module.freeze_keep_charges and not cfg.model.module.freeze_keep_positions + module, vocab, distributions = _build_module(cfg) + load_checkpoint(module, checkpoint) + assert len(vocab.atom_tokens) == 10 + for name, expected in metadata["panel_sha256"].items(): + assert sha(bundle / "panels" / name) == expected + write_json(args.output / "preflight.json", { + "status": "passed", "checkpoint_sha256": metadata["checkpoint_sha256"], + "epoch_zero_based": raw["epoch"], "completed_finetuning_epochs": 25, + "global_step": raw["global_step"], "strict_state_dict_load": True, + "ema_available": any(k.startswith("model_ema.") for k in raw["state_dict"]), + "lora_rank": 16, "insertion_assignment": "substituent", + "atom_tokens": list(vocab.atom_tokens), "rdkit_version": rdBase.rdkitVersion, + }) + print("Preflight passed: epoch 25, rank-16 LoRA, strict checkpoint load, verified panels.", flush=True) + + +class Population(pl.Callback): + def __init__(self, output): + self.output = output + + def on_validation_batch_end(self, trainer, module, outputs, batch, batch_idx, dataloader_idx=0): + if batch_idx % 16 == 0: + print(f"Generated {len(module._scaffold_rows)}/4096 attempts", flush=True) + + def on_validation_epoch_end(self, trainer, module): + rows = module._scaffold_rows + assert len(rows) == 4096 and {r["sample_id"] for r in rows} == set(range(4096)) + assert len({r["scaffold_id"] for r in rows}) == 512 + assert len(module._validation_rdkit_mols) == len(rows) + with (self.output / "attempts.jsonl").open("w") as f, Chem.SDWriter(str(self.output / "generated.sdf")) as writer: + for row, mol in zip(rows, module._validation_rdkit_mols, strict=True): + entry = dict(row) + entry["sdf_written"] = False + if mol is not None: + mol.SetProp("_Name", f"sample_{row['sample_id']:05d}") + try: + writer.write(mol) + entry["sdf_written"] = True + except Exception as exc: + entry["sdf_error"] = str(exc) + f.write(json.dumps(clean(entry), allow_nan=False) + "\n") + print("Saved all attempts and original generated SDFs; computing scaffold and chemistry scores.", flush=True) + + +def evaluate(args): + from run import setup + + start = time.monotonic() + bundle = args.bundle.resolve() + work = args.output.resolve() + upload = json.loads((work / "upload_verification.json").read_text()) + assert upload["status"] == "passed", "Upload must be verified before evaluation" + output = work / f"seed_{args.seed}" + output.mkdir(parents=True, exist_ok=False) + (output / "chunks").mkdir() + metadata = json.loads((bundle / "metadata.json").read_text()) + assert sha(bundle / "epoch_025.ckpt") == metadata["checkpoint_sha256"] + cfg = configuration(bundle, args.data_root.resolve(), args.seed, output) + module, datamodule, trainer = setup(cfg, extra_callbacks=[Population(output)]) + datamodule.setup("test") + panel = datamodule.test_datasets[0] + assert len(panel) == 4096 and len(panel.entries) == 512 and panel.samples_per_scaffold == 8 + assert Path(panel.path).name == "test_murcko_512_seed42.json" + assert sha(panel.path) == metadata["panel_sha256"]["test_murcko_512_seed42.json"] + assert module.use_ema_for_eval + # Save every raw graph before sanitization failures can remove it from SDFs. + original_update = module._update_task_metrics + chunks = [] + + def capture(self, context, mols, rdkit_mols): + path = output / "chunks" / f"batch_{len(chunks):04d}.pt" + torch.save(mols, path) + chunks.append(path.name) + return original_update(context, mols, rdkit_mols) + + module._update_task_metrics = types.MethodType(capture, module) + OmegaConf.save(cfg, output / "config.yaml", resolve=True) + values = trainer.validate(module, dataloaders=datamodule.test_dataloader(), ckpt_path=str(bundle / "epoch_025.ckpt")) + assert len(values) == 1 + metrics = {k.removeprefix("val/"): v for k, v in values[0].items()} + required = {"validity", "novelty", "rdkit-atom-stability", "rdkit-molecule-stability", + "posebusters/validity", "scaffold/posebusters/validity", "scaffold/retention", + "scaffold/recovery", "scaffold/unique_yield", "scaffold/rmsd_aligned"} + assert required <= metrics.keys(), sorted(required - metrics.keys()) + for key, value in {"n_scaffolds": 512, "n_samples": 4096, "min_samples_per_scaffold": 8, "max_samples_per_scaffold": 8}.items(): + assert metrics[f"scaffold/{key}"] == value + assert len(chunks) == 128 + report = { + "status": "completed", "split": "test", "seed": args.seed, + "n_attempts": 4096, "n_scaffolds": 512, "samples_per_scaffold": 8, + "weights": "EMA", "integration_steps": 100, "checkpoint_sha256": metadata["checkpoint_sha256"], + "uploaded_revision": upload["revision"], "metrics": metrics, + "rdkit_stability_population": "all raw generated graphs", + "posebusters_protocol": "native scaffold evaluation: chemistry and distance geometry; scaffold-aware inherited-clash attribution; excludes full offline energy and flatness", + "raw_graph_chunks": chunks, "panel_sha256": sha(panel.path), + "elapsed_seconds": time.monotonic()-start, "job_id": os.environ.get("SLURM_JOB_ID"), + } + write_json(output / "metrics.json", report) + print(json.dumps(clean(report), indent=2), flush=True) + + +def aggregate(args): + work = args.output.resolve() + reports = [json.loads((work / f"seed_{seed}/metrics.json").read_text()) for seed in (42, 43, 44)] + assert all(r["status"] == "completed" for r in reports) + keys = set(reports[0]["metrics"]) + assert all(set(r["metrics"]) == keys for r in reports) + assert len({r["checkpoint_sha256"] for r in reports}) == 1 + assert len({r["panel_sha256"] for r in reports}) == 1 + with (work / "summary.csv").open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=["model", "completed_epochs", "split", "metric", "seed_42", "seed_43", "seed_44", "mean", "std", "n_seeds"]) + writer.writeheader() + for key in sorted(keys): + values = [r["metrics"][key] for r in reports] + finite = [x for x in values if x is not None and math.isfinite(x)] + writer.writerow({"model": "geom-scaffold-murcko-substituent-lora-r16", "completed_epochs": 25, + "split": "test", "metric": key, **dict(zip(["seed_42", "seed_43", "seed_44"], values)), + "mean": statistics.mean(finite) if finite else None, + "std": statistics.stdev(finite) if len(finite)>1 else None, "n_seeds": len(finite)}) + write_json(work / "verification.json", {"status": "passed", "n_seeds": 3, "n_attempts": 12288, "n_metrics": len(keys), + "summary_sha256": sha(work / "summary.csv"), "checkpoint_sha256": reports[0]["checkpoint_sha256"]}) + print(f"Verified {len(keys)} metrics across 3 seeds; all metrics in {work / 'summary.csv'}", flush=True) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("mode", choices=["preflight", "evaluate", "aggregate"]) + p.add_argument("--bundle", type=Path, required=True) + p.add_argument("--data-root", type=Path, required=True) + p.add_argument("--output", type=Path, required=True) + p.add_argument("--seed", type=int, choices=[42, 43, 44], default=42) + args = p.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + {"preflight": preflight, "evaluate": evaluate, "aggregate": aggregate}[args.mode](args) + + +if __name__ == "__main__": + main() diff --git a/geom/scaffold_murcko_substituent_lora_r16/manifest.json b/geom/scaffold_murcko_substituent_lora_r16/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..47af2b71d56c3608adeabd2b5cc6d4992fe757fa --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/manifest.json @@ -0,0 +1,750 @@ +{ + "README.md": { + "bytes": 2752, + "sha256": "3879125c6ede15b8b268f316c5c2163b1af0fb795582c3e4685acb029f52b74f" + }, + "atom_tokens.txt": { + "bytes": 22, + "sha256": "c9b712563ad2509593aa48fddbb5145aa42a70c4791255b8eea2984f549cae6a" + }, + "charge_tokens.txt": { + "bytes": 12, + "sha256": "e2533aa090751f1006a74d165aa2d4a7ac23dbf73a65eb624c2a81216cc7dff5" + }, + "config.yaml": { + "bytes": 14715, + "sha256": "9db9f6c09bc55fb7b39b3b3b37155cefcf3255a8c5eb899fd80c08edf71aa7f1" + }, + "distributions.json": { + "bytes": 220286, + "sha256": "17bcf85c09486e5070aa8b3be4f4411fa01e6b3cf1b39c5488a5f46149f52e0f" + }, + "distributions.pt": { + "bytes": 63581, + "sha256": "40a31704ab58d0349daff35083d43dcf4771ef368fd9c8a8503c94fe844016c5" + }, + "edge_tokens.txt": { + "bytes": 16, + "sha256": "42b635ef938d2ec5a601044e4b01f3f84ed121f1bfaac984d86f180a8ccba3fd" + }, + "epoch_025.ckpt": { + "bytes": 638268597, + "sha256": "0f3b9d75897c951fdfe437bb10735fb371ea2f5e9ea982471c3c7bd73a160484" + }, + "evaluate.py": { + "bytes": 10649, + "sha256": "ad57177b2289610ec10983c9f20480adca7e393166ffec78e9641d3546bc433c" + }, + "metadata.json": { + "bytes": 1511, + "sha256": "f1200bd618873ce5ba3e1d0a3569cd852c6b1fbe6c7523863dfb567541228c1d" + }, + "panels/test_murcko_512_seed42.json": { + "bytes": 226522, + "sha256": "5df2f8b632dd261098ee86581b748487163b10893d2a4462f3e52cd25130905b" + }, + "panels/val_murcko_512_seed42.json": { + "bytes": 224341, + "sha256": "b30a2063ba1c74414a2bda387f9dc42ade8c82540d6ec36c58956c12bee64e44" + }, + "panels/verification.json": { + "bytes": 282, + "sha256": "b8a24405b78b49524bd09fd2593f8686b7910680709aed1956b140c45ffbd342" + }, + "runtime/README.md": { + "bytes": 8819, + "sha256": "5214473be19c41f94f4bace3188e2620cbff69610ee15d495aa97934bd7b30eb" + }, + "runtime/configs/callbacks/callbacks.yaml": { + "bytes": 699, + "sha256": "dce0adebf5956dce687d64a074238f9c12866373bfe21df6658d967d6bfd1f90" + }, + "runtime/configs/data/chembl3d/chembl3d.yaml": { + "bytes": 278, + "sha256": "a96974d90353a850c4a1d0bac27de5f1fc8724b81cc5bbc122aab6f76f3051c5" + }, + "runtime/configs/data/chembl3d/implicit_h.yaml": { + "bytes": 81, + "sha256": "08da196d0288cf1fe4dba8dd407c49d9bee003607504beb59f79e5d6b9eaef0c" + }, + "runtime/configs/data/chembl3d/max100.yaml": { + "bytes": 246, + "sha256": "052dba7b3f3efce990019518823476bd6bf1381bb5f606822c9fc7a485e6d0bf" + }, + "runtime/configs/data/chembl3d/max100_implicit_h.yaml": { + "bytes": 79, + "sha256": "152325f11c7d1500469d4ee381100ae12247e6a2e0b90e5ef7501b405144bb2d" + }, + "runtime/configs/data/crossdocked/finetune.yaml": { + "bytes": 2709, + "sha256": "ba3da996e4c0fd720d6403974f3a3ae851d5defb32a705e931a7bf3f031421f4" + }, + "runtime/configs/data/crossdocked/scaffold_decoration.yaml": { + "bytes": 1171, + "sha256": "4498b88a65912705d49b4a3bf583f41329bbf9637f7250ee85bfbd677a8cb22d" + }, + "runtime/configs/data/geom/geom.yaml": { + "bytes": 428, + "sha256": "736c04e86c029bed65ce3ed022f937dd418ddc70d4b9fc064b38d979bf29ee6d" + }, + "runtime/configs/data/geom/implicit_h.yaml": { + "bytes": 73, + "sha256": "c6b3ff5496af997de0c28e5aa7265903d6d0c8b59aab001ab21d6a64baeb772a" + }, + "runtime/configs/data/geom/scaffold_decoration.yaml": { + "bytes": 1630, + "sha256": "46ca8472be2cd145e526555c0130ccfbfa797c056f4000a9db382dc00edfbed4" + }, + "runtime/configs/data/molecular.yaml": { + "bytes": 5005, + "sha256": "f79ade5ddbf7d08f2eecbb2aa9c3e6b182b8a8e7e48960aff5e5edda5601bdc9" + }, + "runtime/configs/data/qm9/qm9.yaml": { + "bytes": 3648, + "sha256": "3beea93868a1e889b049dcc2ccbc3b7489fa4672e964138654bc512ea01ab9f2" + }, + "runtime/configs/data/qm9/scaffold_decoration.yaml": { + "bytes": 1202, + "sha256": "54cf67421a15625ed9e53e1cb959dc5210106004715cbf6169c9d1bce0a87cfa" + }, + "runtime/configs/default.yaml": { + "bytes": 1294, + "sha256": "bcf9744acbf53afa4305ffd6e9cf95e6f99571750488eaa8cad85896a51aca66" + }, + "runtime/configs/defaults/chembl3d/chembl3d.yaml": { + "bytes": 90, + "sha256": "f09553bc670e6c87ac8c1f8bbd0a8df73f1e83b02a3dcf9d6d06b915ac3bce93" + }, + "runtime/configs/defaults/chembl3d/implicit_h.yaml": { + "bytes": 102, + "sha256": "a0ee1440984eb4c9d3c96a5d1c18df9f59b8e42c499f8a340ccd8deb8c454cd8" + }, + "runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml": { + "bytes": 331, + "sha256": "518571405333a40e1602ee4c77fd153b56fd53e77aaa2aaf530796142f58d4b8" + }, + "runtime/configs/defaults/chembl3d/train_2x4090.yaml": { + "bytes": 826, + "sha256": "0e6b01b264ab1ae70ae7f144ecd0a03d242e24f02feb087e3d5e2b7bc24790da" + }, + "runtime/configs/defaults/core.yaml": { + "bytes": 431, + "sha256": "0dcfb68e2fece1dc277f647d3b3769af882cf8175e0c3b479bc50029e2538b1c" + }, + "runtime/configs/defaults/crossdocked/finetune.yaml": { + "bytes": 364, + "sha256": "d244463c3049387369b5d1d709986246e7cc6a77218a2147df0820b9fd8c5173" + }, + "runtime/configs/defaults/crossdocked/scaffold_decoration.yaml": { + "bytes": 240, + "sha256": "db421328b9df620fee7f19c99cf10bc6a492f6eb81e17df67e72c7f1bd1da70f" + }, + "runtime/configs/defaults/crossdocked/scaffold_decoration_2x4090.yaml": { + "bytes": 1187, + "sha256": "e6b3e0d004213db5d1291db43c21e8ada0f8b3be4b634a2615eb3a0fb58c7d23" + }, + "runtime/configs/defaults/geom/geom.yaml": { + "bytes": 425, + "sha256": "f447a1bdd39e211b5cf43c4cce56e708da6472d58f79e87885621ec4899c1c8e" + }, + "runtime/configs/defaults/geom/implicit_h.yaml": { + "bytes": 98, + "sha256": "9d3a188e6bd2a2b01609ba0d652184bb8a2f7e27fd89d4a3031568051cae605e" + }, + "runtime/configs/defaults/geom/scaffold_decoration.yaml": { + "bytes": 1192, + "sha256": "dda529d12a8dfd3131dddc3baf7893ea6443c5f9fc9736704d8198f669ae4b7b" + }, + "runtime/configs/defaults/geom/scaffold_decoration_2x4090.yaml": { + "bytes": 692, + "sha256": "301bf8d7446a6d9c10064ffc6c73b704ef15b4fe9bcc48aa8ce89ff732c632a1" + }, + "runtime/configs/defaults/geom/scaffold_decoration_scratch_2x4090.yaml": { + "bytes": 955, + "sha256": "728e19428775722e8107f5ee069be1f9533bd43b22061648978369f686bad887" + }, + "runtime/configs/defaults/qm9/qm9.yaml": { + "bytes": 429, + "sha256": "46070ea792300c0817c0acdff256181b21ef17dfe9f72070bb12da27dc28d534" + }, + "runtime/configs/defaults/qm9/scaffold_decoration.yaml": { + "bytes": 694, + "sha256": "0fecd61454aec70caf87aa397e01eb0eaa2eb5072256d75c6c541196c5aedaa4" + }, + "runtime/configs/defaults/qm9/train_2x4090.yaml": { + "bytes": 554, + "sha256": "43a835246afb7126786e8ffa71c8b80ed9db1ef3a16e5240f1eec7882a4ef5ba" + }, + "runtime/configs/embeddings/geom.yaml": { + "bytes": 803, + "sha256": "06793f4d446d899197e3db28d064592af9049612ee78cb0718a63e3d6fdc3151" + }, + "runtime/configs/embeddings/qm9.yaml": { + "bytes": 849, + "sha256": "de57af7f1ff9b44dbb952f852c0177789196105b77f396298ff8e0218c0626b7" + }, + "runtime/configs/heads/head.yaml": { + "bytes": 2475, + "sha256": "238e2346041e0aedccd16a6ed4066e163004c0e45a2cbc6fd1f6d17e16eb44ed" + }, + "runtime/configs/insertion_head/gmm.yaml": { + "bytes": 488, + "sha256": "1e26914eed9b4971e0f6ff0b61bbdb34103233d346719a55ba6273dd3d785c05" + }, + "runtime/configs/insertion_head/unimodal.yaml": { + "bytes": 495, + "sha256": "547a6391c3b341e3e369f8f4c7aab9752c67dfbbb533388d25956e9011b305e8" + }, + "runtime/configs/integrator/integrator.yaml": { + "bytes": 1580, + "sha256": "8f0d9aafb0b2727cd58154e1fd97df693c8b7d4f5a3fb5efcb9188553f54660f" + }, + "runtime/configs/logging/wandb_logger.yaml": { + "bytes": 139, + "sha256": "a9a2787e048d4eb70f2ff3c49d4355f1a5a382047c8619203b1f9131d829928d" + }, + "runtime/configs/loss_weights/calibrated_geom.yaml": { + "bytes": 644, + "sha256": "d4763c84aa9e1eb77fb2b456384332a4b3a84b072d560918b2357222a43f4ca1" + }, + "runtime/configs/loss_weights/calibrated_geom_frozen.yaml": { + "bytes": 1141, + "sha256": "a08acb61d67a852051dbded08f8bb67f21101888fd0a22a96a522781d28c570d" + }, + "runtime/configs/loss_weights/tuned_weights.yaml": { + "bytes": 420, + "sha256": "2bb5884465666d6fc638f787fd79257e4126c69a5cc55b55615fc45e02e65894" + }, + "runtime/configs/loss_weights/uniform.yaml": { + "bytes": 336, + "sha256": "3c14d6443048f69d436fe03792703a33c67f368ecbb349de2fd145da4c96eabc" + }, + "runtime/configs/model/backbones/dit.yaml": { + "bytes": 4039, + "sha256": "65b0f08be8fb307b8b01e7b460fd083fe60d48148fbece378d7e5a23dd4a9fb0" + }, + "runtime/configs/model/finetune/lora_finetune.yaml": { + "bytes": 443, + "sha256": "973cb978622d19e08a762acf9e918a2279f02ad8501b33d3adb3476ca450726c" + }, + "runtime/configs/model/finetune/pocket_finetune.yaml": { + "bytes": 959, + "sha256": "4d17768a2a884b6e083f06103c5220a737f68cfea87fa4882f112d79a3e54bfa" + }, + "runtime/configs/model/finetune/scaffold_finetune.yaml": { + "bytes": 6097, + "sha256": "dbe7adc88454cc52ea4aab9d420b93d5efa78e03003e5e85de6b6733fa1046b3" + }, + "runtime/configs/model/finetune/scaffold_pocket_finetune.yaml": { + "bytes": 634, + "sha256": "b09d57280abc777773bba4c3d1b8c947b85b2fa73c2e7ea21924c49691a98d93" + }, + "runtime/configs/optimizer/muon_aux_adam.yaml": { + "bytes": 1212, + "sha256": "3b0a6189f3ba81f85f7d26c6f6ad7f64dcb25098a14e411211d7a62983a7180c" + }, + "runtime/configs/schedule/beta.yaml": { + "bytes": 78, + "sha256": "75c485edfd70016b255282adcf5de25dfb95c9e32bb6587017fb8935ffe64eaa" + }, + "runtime/configs/schedule/cubic.yaml": { + "bytes": 54, + "sha256": "a998a2d3390ab5daf4df079852ae8daa907687cf059f55c500409c9493949c0c" + }, + "runtime/configs/schedule/fast_power.yaml": { + "bytes": 126, + "sha256": "22f36106bd43ca253ffea412e00539f2b07efbc49109b97c1232df6a827a2baf" + }, + "runtime/configs/schedule/linear.yaml": { + "bytes": 55, + "sha256": "f7881af26080ab5569e67b39f6eae2733c033b9623020e6271959f09fac45fa0" + }, + "runtime/configs/schedule/smoothstep.yaml": { + "bytes": 70, + "sha256": "f6e8651953d93f0d18c997d40c199b3d80ddd352a2041cc51e29adc9a7f22778" + }, + "runtime/configs/trainer/trainer.yaml": { + "bytes": 1402, + "sha256": "6f78e83ad46529bee7a2878b20727ff403420a515c1629bd73a608a2fb654f5f" + }, + "runtime/pyproject.toml": { + "bytes": 4537, + "sha256": "d88a9882c2e441ce342ac72907b38585a6d9f8e45474b9417020bb3ecfa47ef2" + }, + "runtime/run.py": { + "bytes": 9958, + "sha256": "1911e4fc827f2dd0344ab5a508faeb43cd364fc88c3a547223e34704aa7142c0" + }, + "runtime/src/morph/__init__.py": { + "bytes": 79, + "sha256": "bb402ea5c6f79e569205cc2bf4168d06430753ce8499ca4ffb8614743626fd59" + }, + "runtime/src/morph/__main__.py": { + "bytes": 149, + "sha256": "95a7983479559ca2b15c7afa3881b3142ef4636583501184469b972df7646d4f" + }, + "runtime/src/morph/_vendor/__init__.py": { + "bytes": 72, + "sha256": "8480cd966a03870a7142e1763094bf00e221d2c3c2b1af1d4d458fb8b7f37a20" + }, + "runtime/src/morph/_vendor/geom_drugs_preprocessing.py": { + "bytes": 11328, + "sha256": "33a9c21d29df269a5f342a0b0e8bced0fc620559f0cb3a7a4cfabd5c369ceccb" + }, + "runtime/src/morph/_vendor/muon.py": { + "bytes": 16405, + "sha256": "c61af8c2c4a635cc682b84e279851cb54fedc6fb935d8eb11aafbeb1c5e81fc6" + }, + "runtime/src/morph/cli.py": { + "bytes": 9723, + "sha256": "27eee3fd6d6410747f12b19b670a74f50a51b842a50b1eff4e75c46d0d08a986" + }, + "runtime/src/morph/config.py": { + "bytes": 704, + "sha256": "bd3562c030611f87cc514308f155ad93740dd59927732f71b2e15009012d78ff" + }, + "runtime/src/morph/dataset/__init__.py": { + "bytes": 620, + "sha256": "44ae493354fed0fec4b410f46e0c1fb0351b5fbe5f404ff50f77181e60a39cee" + }, + "runtime/src/morph/dataset/conditional/__init__.py": { + "bytes": 316, + "sha256": "8152efe1b0878297add6646862f56304e38459b894a77817adbd652bee7e334a" + }, + "runtime/src/morph/dataset/conditional/pocket/README.md": { + "bytes": 19246, + "sha256": "2d2d8e20160e5554b40cc54258e43a05938c40bd5232565b47494b94259e562c" + }, + "runtime/src/morph/dataset/conditional/pocket/__init__.py": { + "bytes": 190, + "sha256": "c69f5024a5a90a02f08b31402e16e6eac8598b55e9c6433bda72c0394abd0963" + }, + "runtime/src/morph/dataset/conditional/pocket/build.py": { + "bytes": 13844, + "sha256": "fa645b28b00a39b8abd3550d3e4d2988b6c89e8cd5fac4b52e764877d8661759" + }, + "runtime/src/morph/dataset/conditional/pocket/crossdocked.py": { + "bytes": 12798, + "sha256": "9eab93363712132e5b1062d808bc935558b35fb5869ef6b2a638aef98470067b" + }, + "runtime/src/morph/dataset/conditional/pocket/datamodule.py": { + "bytes": 4261, + "sha256": "cfdcedea2b96b96261af5a17eb4dddc7f471e2b0b7b444b630139d3103f82349" + }, + "runtime/src/morph/dataset/conditional/pocket/evaluation.py": { + "bytes": 9050, + "sha256": "270574b07aa19667f97336a6a38e6e9c93a7e09fde82f80d409640b27762adc6" + }, + "runtime/src/morph/dataset/conditional/pocket/processing.py": { + "bytes": 7734, + "sha256": "eb9059ea7e240995992a86920e9e4e77b1fa6130d63327fa82a86239ff766250" + }, + "runtime/src/morph/dataset/conditional/pocket/scaffold_pocket.py": { + "bytes": 5518, + "sha256": "987fc333b34e3e2d25ed7407a5228696573f1c39ef93901c201f7957dde4fa39" + }, + "runtime/src/morph/dataset/conditional/replay.py": { + "bytes": 7061, + "sha256": "fc8ef66f1be371c4252015d8f4b7e555a976e669cf3b2606a44403552bfc0554" + }, + "runtime/src/morph/dataset/conditional/scaffold/__init__.py": { + "bytes": 229, + "sha256": "ef9b1d37a3a18e820dd1b9afefe1849d066d70cbdd93a02b87c09f76e0a9b3b0" + }, + "runtime/src/morph/dataset/conditional/scaffold/cutters.py": { + "bytes": 15539, + "sha256": "08c3992621a3f930d2595361a86e13280f2f0045c6875eb5c0045ccb9946e5bf" + }, + "runtime/src/morph/dataset/conditional/scaffold/datamodule.py": { + "bytes": 2182, + "sha256": "3a1c4ffb8599970e6fe81cd9d462dcabcb44091e446b4eb045868022cb1b19a1" + }, + "runtime/src/morph/dataset/conditional/scaffold/libinvent_filter_conditions.json": { + "bytes": 560, + "sha256": "ac33112e64f47991be40259aa694219a7f1d47f36a4a130cf329aa98205529b2" + }, + "runtime/src/morph/dataset/conditional/scaffold/libinvent_reactions.smirks": { + "bytes": 4272, + "sha256": "0eaf5444865fef1c5c6d5b87ec97f9480a4765fde97cfa6dbbe57d718f9f74ef" + }, + "runtime/src/morph/dataset/conditional/scaffold/lmdb.py": { + "bytes": 4864, + "sha256": "18ee5fcb3fd7ecad0b0b5e1f2bb25e5196e05e492a5e061ef17139a2efcdadc3" + }, + "runtime/src/morph/dataset/conditional/scaffold/panel.py": { + "bytes": 9440, + "sha256": "54aa9377cfd0b3a6883adc6955b7b7164bfc030a20fd901d75c221f551b61036" + }, + "runtime/src/morph/dataset/conditional/scaffold/wrappers.py": { + "bytes": 8473, + "sha256": "69d58714aef4dbf3d4fb953ff62befc64f021eb9afc5fbc04dd9e7f7f8638ecc" + }, + "runtime/src/morph/dataset/core/__init__.py": { + "bytes": 376, + "sha256": "7bab9c675f0850efc50e14a9898accfde5f8d1c1e2420414ac8c0986822d27e0" + }, + "runtime/src/morph/dataset/core/atomic_data.py": { + "bytes": 708, + "sha256": "7d59ec16ed5b3cf82b57b49dd4d7799862709353a65f4d473d4571ba91b86fa6" + }, + "runtime/src/morph/dataset/core/batch.py": { + "bytes": 23924, + "sha256": "7b2b2afd749b621b1ca890d192e4dc9378b61ead9ef34b90f04dc449e6bad369" + }, + "runtime/src/morph/dataset/core/containers.py": { + "bytes": 19637, + "sha256": "adae656c3fbbe5cfe32f378a419c7e1076f653163ffe7d2c1d1d0abc71a99cd7" + }, + "runtime/src/morph/dataset/core/conversion.py": { + "bytes": 2224, + "sha256": "0842565c503048a86a517c202dd0cc7c07e9296a841601b178ec38c43f903a6a" + }, + "runtime/src/morph/dataset/core/flow_matching_wrapper.py": { + "bytes": 8406, + "sha256": "650d791f765f90f5f86353d3edbeb65f2d44bc9089b9123ec407c970c144beb0" + }, + "runtime/src/morph/dataset/core/insertion_data.py": { + "bytes": 14393, + "sha256": "7e99e4d59b29ea537708b5f7887ed3e61b4b31ebf80d55fe5a0c6823ff8a439c" + }, + "runtime/src/morph/dataset/core/insertion_targets.py": { + "bytes": 6614, + "sha256": "2e62e89125310572735d916e97c8a544cfb3eb76fb167ecd134b5f8b0424df8c" + }, + "runtime/src/morph/dataset/core/lightning_datamodule.py": { + "bytes": 7495, + "sha256": "1facb3c332fd3d306631c84aede122be89b54bafbd17eeab25981b1e4a6af7f3" + }, + "runtime/src/morph/dataset/core/lmdb.py": { + "bytes": 10111, + "sha256": "748b7a10f8fb20f74c267de393cc3e1933ea86debde99d8fdaaef94e0a9afd29" + }, + "runtime/src/morph/dataset/core/preprocessing.py": { + "bytes": 20145, + "sha256": "da9cffd6beb6da63ee749dd8b60d4d0509bcd937778014739b04ffc599dde066" + }, + "runtime/src/morph/dataset/core/properties.py": { + "bytes": 1952, + "sha256": "4dc63574f5fde284245078adf50fb2d8a9ac16443b8276154c03b7703cc2e937" + }, + "runtime/src/morph/dataset/core/representation.py": { + "bytes": 6591, + "sha256": "0ecac55e1faf9748826ec8f4c7f1a274263214ea3f68cc69de7047b55dd6a65f" + }, + "runtime/src/morph/dataset/core/tokenized_decode.py": { + "bytes": 6559, + "sha256": "483afb3d468113da33cc8699998e6439fcde2264ee7ca479724206360584a2b8" + }, + "runtime/src/morph/dataset/core/vocab.py": { + "bytes": 2236, + "sha256": "f8618488f7bcdc8a58d8b9f3443b764eac60985a33ead1bcc2d17d3bdc213329" + }, + "runtime/src/morph/dataset/unconditional/__init__.py": { + "bytes": 403, + "sha256": "d7cd3b85745ad1a4d884c0a11a64b3ab9cedcc64e30f604dbe83f5beb14fa97b" + }, + "runtime/src/morph/dataset/unconditional/chembl3d.py": { + "bytes": 9854, + "sha256": "d5cfd1ee4ddd3085b95b9a678d761d4f0beef99ac5ecfb21d3dfb3cae59653eb" + }, + "runtime/src/morph/dataset/unconditional/geom.py": { + "bytes": 4071, + "sha256": "12bc792c2a61b3e6c29ae265b9997ba610765e268408e873918b5a65225945be" + }, + "runtime/src/morph/dataset/unconditional/qm9.py": { + "bytes": 9799, + "sha256": "a23fdb22e86597ae19cf4b8435f82ffcb9c16c32d0dc729a5ca1019aef8fd78a" + }, + "runtime/src/morph/flow_matching/__init__.py": { + "bytes": 78, + "sha256": "674f4630aa8421d7e75c913741c6efb1a163fbc38ce909c6cdb011f9ba9ef48e" + }, + "runtime/src/morph/flow_matching/assignment.py": { + "bytes": 16050, + "sha256": "b6ad66538f5bd7f14e0eee2a0ffd25a4110d1504e057a0a9eb63a354d9f6aadb" + }, + "runtime/src/morph/flow_matching/integration.py": { + "bytes": 33356, + "sha256": "f64b7b9ed2a79c11932b72e498e57eef924b5c85a05aa6893ddfe11e2d4595e9" + }, + "runtime/src/morph/flow_matching/interpolation.py": { + "bytes": 23963, + "sha256": "81939bb12a705f92394d1d2b78d4cf13b592bb20a129524020cc5236074c8072" + }, + "runtime/src/morph/flow_matching/prior.py": { + "bytes": 2389, + "sha256": "0f9b77274238b23be7a2ad88ee8e6dcbb785817fcfa3a35aa863b489d2a6e05d" + }, + "runtime/src/morph/flow_matching/sampling_step.py": { + "bytes": 2806, + "sha256": "c768682a47da1b7852838e20b39317528717fc0485cbc35030b3ca71d7d845bb" + }, + "runtime/src/morph/flow_matching/scaffold_insertion.py": { + "bytes": 2886, + "sha256": "18e0d7093dfaab98b253d202c4fc4649b8b8de96c9a22144c5eeb5041f73aef8" + }, + "runtime/src/morph/flow_matching/schedules.py": { + "bytes": 6776, + "sha256": "6fd8396a05052ae7831babc75ca40a7e748fb9edffe82bb2559533afa949b2a9" + }, + "runtime/src/morph/inference/__init__.py": { + "bytes": 732, + "sha256": "63047340b4c7ed784e969b7d54035ab737282ee2acc6eeb26ba6cb3d52b4f573" + }, + "runtime/src/morph/inference/bundles.py": { + "bytes": 6395, + "sha256": "f6e2d46e6e428452a58208e78c83da01d6c786f10f7062643a093fb6750dafe6" + }, + "runtime/src/morph/inference/catalog.py": { + "bytes": 6215, + "sha256": "53a27b86d39641fd4964da82f4b1875598a0cdb1533f981074c6b7a14b4174b7" + }, + "runtime/src/morph/inference/generate.py": { + "bytes": 11889, + "sha256": "c3181fbf6187cf22b476431ed7c0fdea4c74ea8e97acf270f486318e43646d19" + }, + "runtime/src/morph/inference/hub.py": { + "bytes": 9739, + "sha256": "fa1f43937d66f0ab4b7b9c11ed749aef8d23444b2df3257c36ff72ecd3b821ca" + }, + "runtime/src/morph/inference/module.py": { + "bytes": 3820, + "sha256": "415e0f9c624c191855874b8dd88d5e7aa18941632bc403a9c0b685bbf74f1d60" + }, + "runtime/src/morph/inference/registry.py": { + "bytes": 4314, + "sha256": "8ca3e4246e00707ed0d646e6e760acebd5e3d3443e5e8d5f71f3b54653993fa9" + }, + "runtime/src/morph/model/README.md": { + "bytes": 10319, + "sha256": "9c06b5b90300f7449048640417116aa8869dc1a75f106cbdd9344a89f6e446d3" + }, + "runtime/src/morph/model/__init__.py": { + "bytes": 80, + "sha256": "1c7c3875387d5ba7f9166a804ea5a7ef7bd469728c1ad5c5003150d44e33be51" + }, + "runtime/src/morph/model/backbones/__init__.py": { + "bytes": 64, + "sha256": "a1a1419590c11aa6ed429f8b6b293470e30fe767f5571d2517b0883efb071e0c" + }, + "runtime/src/morph/model/backbones/conditional_dit.py": { + "bytes": 25614, + "sha256": "15aacd0afd334a6d296556a00e5e8849a5dbf021b37c55f1f96af6c9fe706c80" + }, + "runtime/src/morph/model/backbones/dit.py": { + "bytes": 31994, + "sha256": "7d9e1f25cc4174bb9f2e03f57679c4b2d28a292740e3833ac38870d172bbe805" + }, + "runtime/src/morph/model/build.py": { + "bytes": 3442, + "sha256": "e69f4231815f09c094d787f11edc49e222cbdc970edc6e416b6b94a9b88d4b21" + }, + "runtime/src/morph/model/finetune/__init__.py": { + "bytes": 282, + "sha256": "8746b1e29e5a3b3b1805389257270b90f5d30995783f2cf41b9fb533c485fc0c" + }, + "runtime/src/morph/model/finetune/base/__init__.py": { + "bytes": 803, + "sha256": "6e0ded0b3a5c2a76ccb85b05cf63bcc54e145bd1be07d933a737d6151433d856" + }, + "runtime/src/morph/model/finetune/base/conditional_backbone.py": { + "bytes": 2853, + "sha256": "1353e96443eacf3ce88473a30d02ab4f1c8b6a9c90763ea61c58d353b806b378" + }, + "runtime/src/morph/model/finetune/base/context_encoder.py": { + "bytes": 13655, + "sha256": "095784c710fd921c9da4114dd867df636513a696beece28cfa9a79e1d8193320" + }, + "runtime/src/morph/model/finetune/base/finetune_base.py": { + "bytes": 10575, + "sha256": "06b38586346545b6ff19e689944d4c5a6da04431d0b10eefc58c8ee43e88ef46" + }, + "runtime/src/morph/model/finetune/pocket/__init__.py": { + "bytes": 455, + "sha256": "bd83b0aec910454a989214169a9160ef744ec1f115044fceba8fcb3d1d208edd" + }, + "runtime/src/morph/model/finetune/pocket/pocket_context.py": { + "bytes": 2913, + "sha256": "c9d00636bbaa7ba413f4299894e6db28b82e02680005d261d1f7cb52df26ea91" + }, + "runtime/src/morph/model/finetune/pocket/scaffold_pocket_context.py": { + "bytes": 1476, + "sha256": "7c5e3424c7b68ea1dff5d516c1010e0fd5bb417966262eea3896908b6971847f" + }, + "runtime/src/morph/model/finetune/scaffold/__init__.py": { + "bytes": 366, + "sha256": "6345e71c8b03ee5eb0dcd105c0532acf73a1795bf6360b2847be600bcecd40ba" + }, + "runtime/src/morph/model/finetune/scaffold/scaffold.py": { + "bytes": 5643, + "sha256": "54c27926aff97448521be3cd28e8560dda696ae1f9208dbbe3ad79a8e856597f" + }, + "runtime/src/morph/model/finetune/scaffold/scaffold_context.py": { + "bytes": 6086, + "sha256": "d9446254770e3c3ea2c634a0636ab82a718514a7cb69760d756e9235cfbe2814" + }, + "runtime/src/morph/model/layers/__init__.py": { + "bytes": 189, + "sha256": "79671fbdea358c330a45b7fd3d842119b32b1d548804b3d441bbfb7dbd703b69" + }, + "runtime/src/morph/model/layers/attention.py": { + "bytes": 22828, + "sha256": "366f4b7618097715e5a50acbcf7fae3f1fe7015810e6dfd69ce2e657862cdfba" + }, + "runtime/src/morph/model/layers/embedding.py": { + "bytes": 14356, + "sha256": "efe924190da5904e05495f421dcc313134cebe0dee9bf665060e1e5781cd95ef" + }, + "runtime/src/morph/model/layers/gmm.py": { + "bytes": 6753, + "sha256": "628e90c73bdbb3e70104214f80bbf8a94896ddbe22a9a3fc40cce34b20d2565f" + }, + "runtime/src/morph/model/layers/heads.py": { + "bytes": 30913, + "sha256": "7113c29a6be81c93ca980dc19d0847d2015ac36702683e4f1adedd549aed46ae" + }, + "runtime/src/morph/model/layers/kernels.py": { + "bytes": 12483, + "sha256": "be0c52b69c7612946244755e2865de0491440a41163cf828168eb750a8f929cf" + }, + "runtime/src/morph/model/layers/lora.py": { + "bytes": 5947, + "sha256": "7194750605e7b5df9a02e4c4a39cf29c982f117c40198c99c73211c85ecdfe4b" + }, + "runtime/src/morph/model/lightning/__init__.py": { + "bytes": 339, + "sha256": "c53943a4eb1e45568cebfb538655cc5eb7ead46284bbcd515800102c89177e52" + }, + "runtime/src/morph/model/lightning/_optimizer.py": { + "bytes": 19578, + "sha256": "f77519db8d3458c05aec1543cb4cd487995cf398abcc71b62ea1835cb132165e" + }, + "runtime/src/morph/model/lightning/_sampling.py": { + "bytes": 15588, + "sha256": "ab5f423a6910939552a62c2da645ea1e9afcd763bc8b7e0dbfc75953825a9dca" + }, + "runtime/src/morph/model/lightning/_setup.py": { + "bytes": 6537, + "sha256": "835f510094110c4dbc590a88d9b94d41474ae87b442785a949328feda5b9afce" + }, + "runtime/src/morph/model/lightning/_training.py": { + "bytes": 2747, + "sha256": "2d298e8b0833cce18e4bdef76e67bf81b49a9fdca9920868e2e4083fcc398fc4" + }, + "runtime/src/morph/model/lightning/_validation.py": { + "bytes": 12558, + "sha256": "82c9e1a9e2c54827c7ec92dcdb857c6d2bf050b73416ff028e022c86a2710a75" + }, + "runtime/src/morph/model/lightning/base.py": { + "bytes": 9294, + "sha256": "ce9159e7b387ece0a3ce010b164f675b81980d2f617b5b05d00f96695ee33e28" + }, + "runtime/src/morph/model/losses/__init__.py": { + "bytes": 393, + "sha256": "dc20020e6686703a5b0abd0e77b451fc4beace021f22fd6f92d988a48cb4b526" + }, + "runtime/src/morph/model/losses/flow.py": { + "bytes": 21766, + "sha256": "a8e63b363e8e800c5d22d80454999bd8c5d91bb75ff2f9842800bed8e3377e04" + }, + "runtime/src/morph/model/losses/functional.py": { + "bytes": 11765, + "sha256": "42ae52f604a0cb8b7fe08955cfd273945ca068727994f20b02c80cdee3a00e1b" + }, + "runtime/src/morph/utils/__init__.py": { + "bytes": 78, + "sha256": "eb76ca86f046931eaea693a696317e8b3ce0b8ad8695a92e3744fd1892f2b9b8" + }, + "runtime/src/morph/utils/checkpoints.py": { + "bytes": 2301, + "sha256": "750302eb039e3947a5d4c84e4e7d04eb46e974cc0031ef1d197a3573e20eae4b" + }, + "runtime/src/morph/utils/edge_alignment.py": { + "bytes": 4896, + "sha256": "0edcde93cfa3a2d825e783ea5cf64715aa248e59ffb8c6a23d4490d74976a71e" + }, + "runtime/src/morph/utils/graph_math.py": { + "bytes": 6755, + "sha256": "2bae326c3538d757aac66dcfc4a0b5d023b17bd987d814d3dab9a57f9ce60552" + }, + "runtime/src/morph/utils/loss_accumulation.py": { + "bytes": 15115, + "sha256": "0f3eca42145e715290576dd86f4bcfb1553714ec4cabeda9246cfaf1e4f4323c" + }, + "runtime/src/morph/utils/lr_schedulers.py": { + "bytes": 5554, + "sha256": "b954c12df413ca6676263ef1af960e2a4ff52692acfd4223e72a6e02122a3638" + }, + "runtime/src/morph/utils/metrics/DIFFDEC_LICENSE": { + "bytes": 1074, + "sha256": "729bec7640e96b6357728566e43ac7dec9b7393f15c8ee96edf1e699c00e91f1" + }, + "runtime/src/morph/utils/metrics/__init__.py": { + "bytes": 7120, + "sha256": "ab3cfce2cc6ce7d7cdacedefe91d195c6783e0d6d21762beb6cb4446f47deb32" + }, + "runtime/src/morph/utils/metrics/chemistry.py": { + "bytes": 36408, + "sha256": "db05302c87b6880167be67dc1d5f7b35924bb84f6a7a0b5b32d2d62e54a00735" + }, + "runtime/src/morph/utils/metrics/denovo.py": { + "bytes": 26664, + "sha256": "57517d1124c6067a1682e13f6faa439ecbceb3d8c12ea0d13f6da0db503cd5c9" + }, + "runtime/src/morph/utils/metrics/diffdec.py": { + "bytes": 7879, + "sha256": "37d334fb306afd114de2f50ee4ad5dc48f7565adaa6a077a08e01fc1460599c8" + }, + "runtime/src/morph/utils/metrics/distribution.py": { + "bytes": 3348, + "sha256": "0a6c25c4e65843308059a3effc44e1072837e747926074c1d7c47ba7ee72cb46" + }, + "runtime/src/morph/utils/metrics/plotting.py": { + "bytes": 4270, + "sha256": "686d491d7fbc83cf35f772649a869dc1a271966d2a4fa6162d6e2334db735723" + }, + "runtime/src/morph/utils/metrics/posebusters_energy.py": { + "bytes": 3567, + "sha256": "a705c1baa499a5a4e95329f941f6c762fb49feb505819ed0a35b34daf46ac521" + }, + "runtime/src/morph/utils/metrics/posecheck_strain.py": { + "bytes": 6531, + "sha256": "1fd8340484f542d6a9abce041b8caee20901b9d704bc354f37baf67c76d156a2" + }, + "runtime/src/morph/utils/metrics/scaffold.py": { + "bytes": 6312, + "sha256": "44965940eb17c661734ab0a816123ac5e50a33103aaca217596cca697fd8be6d" + }, + "runtime/src/morph/utils/metrics/scaffold_posebusters.py": { + "bytes": 3825, + "sha256": "d5372df116119a55074cab7ceae085568be4edbb0c932b0961a1ed19bb453f83" + }, + "runtime/src/morph/utils/metrics/tensor.py": { + "bytes": 13503, + "sha256": "3fcb867cde5d47f81725acc3b862fc56af5ecce23dee3a11ebe6d05c1772c6fe" + }, + "runtime/src/morph/utils/rdkit_utils.py": { + "bytes": 9053, + "sha256": "0da70d65eb85d9906e4b611ffa087a870f6133df6ea99a9b21a35b55ad57ffc5" + }, + "runtime/src/morph/utils/run_config.py": { + "bytes": 8025, + "sha256": "0c289120887a051379523af528ebc9310f356f3b92efb898ddaf773dbf698aa2" + }, + "runtime/src/morph/utils/vocab_utils.py": { + "bytes": 6343, + "sha256": "7b5506971b5092865b86311173b24d7fb70a881f123c57fd8696c412a25d618b" + }, + "runtime/uv.lock": { + "bytes": 615473, + "sha256": "4b85cc8b6e28d7fe42533df16cc9633d909fd91296b38b3d8ebce3dbbf8317aa" + }, + "training_config.yaml": { + "bytes": 15526, + "sha256": "7243310c19f0d90399db52845e8ad0446d66e303b64863cbce444058e1bf06bf" + }, + "training_source.tar.gz": { + "bytes": 510894, + "sha256": "f2ee1bf919ad17d664f0614252f43bdcf39b2474e5a1069a37429a520adb8a2f" + } +} diff --git a/geom/scaffold_murcko_substituent_lora_r16/metadata.json b/geom/scaffold_murcko_substituent_lora_r16/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..8c807b01fe87ff711bcac4e6464dbbe67da8491f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/metadata.json @@ -0,0 +1,41 @@ +{ + "task": "scaffold_decoration", + "dataset": "GEOM-Drugs", + "hydrogens": "explicit", + "completed_epochs": 25, + "checkpoint_epoch_zero_based": 24, + "global_step": 199100, + "checkpoint_sha256": "0f3b9d75897c951fdfe437bb10735fb371ea2f5e9ea982471c3c7bd73a160484", + "checkpoint_bytes": 638268597, + "original_checkpoint": "/cluster/project/krause/frankem/morph-chem/logs/training/geom-scaffold-substituent-lora-r16-20261012T095326Z/run/every_n_epochs/epoch=24-step=199100.ckpt", + "training_job_id": 16886672, + "model": "geom-scaffold-murcko-substituent-lora-r16", + "lora_rank": 16, + "lora_alpha": 16, + "base_model": "geom-explicit-h", + "base_completed_epochs": 100, + "base_checkpoint_sha256": "0ab5ad7247b5518858c8cb6aa7c4102352e8fe09cbb6744c33a4c784c85b8587", + "insertion_assignment": "substituent", + "freeze_keep_positions": false, + "freeze_keep_charges": true, + "default_eval_weights": "ema", + "integration_steps": 100, + "original_checkpoint_including_optimizer": true, + "source_training_archive": "training_source.tar.gz", + "evaluation_runtime": "runtime", + "panel_sha256": { + "val_murcko_512_seed42.json": "b30a2063ba1c74414a2bda387f9dc42ade8c82540d6ec36c58956c12bee64e44", + "test_murcko_512_seed42.json": "5df2f8b632dd261098ee86581b748487163b10893d2a4462f3e52cd25130905b" + }, + "evaluation_protocol": { + "split": "test", + "n_scaffolds": 512, + "samples_per_scaffold": 8, + "seeds": [ + 42, + 43, + 44 + ] + }, + "not_unconditional_catalog_entry": true +} diff --git a/geom/scaffold_murcko_substituent_lora_r16/panels/test_murcko_512_seed42.json b/geom/scaffold_murcko_substituent_lora_r16/panels/test_murcko_512_seed42.json new file mode 100644 index 0000000000000000000000000000000000000000..cc549a3e0f924f64e27436bed70fa6bedaae4f73 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/panels/test_murcko_512_seed42.json @@ -0,0 +1,13692 @@ +{ + "metadata": { + "version": 1, + "split": "test", + "dataset": "/cluster/project/krause/frankem/morph/data/geom/processed/test_data.lmdb", + "records": 706568, + "storage": [ + 5627314176, + 1779831343092593000 + ], + "kind": "murcko", + "n_scaffolds": 512, + "seed": 42 + }, + "candidate_molecules": 27549, + "candidate_scaffolds": 18111, + "entries": [ + { + "base_idx": 580626, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 31, + 32 + ], + "molecule_smiles": "CCCN1CCC(NC(=O)[C@H]2CC=CC[C@@H]2C(=O)O)CC1", + "scaffold_smiles": "O=C(NC1CCNCC1)C1CC=CCC1" + }, + { + "base_idx": 177487, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 42, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "Cc1nc2cnc3ccc(C#Cc4cccnc4)cc3c2n1-c1ccc(C(C)(C)C#N)cc1", + "scaffold_smiles": "C(#Cc1ccc2ncc3ncn(-c4ccccc4)c3c2c1)c1cccnc1" + }, + { + "base_idx": 447150, + "atom_indices": [ + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 22 + ], + "molecule_smiles": "CNC(=O)[C@@]1(C(F)(F)F)C/C(=C\\CCO)CCCO1", + "scaffold_smiles": "C=C1CCCOCC1" + }, + { + "base_idx": 152123, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "Cn1nnnc1SCSc1nnnn1C", + "scaffold_smiles": "C(Sc1nnn[nH]1)Sc1nnn[nH]1" + }, + { + "base_idx": 182994, + "atom_indices": [ + 1, + 2, + 27, + 28, + 29 + ], + "molecule_smiles": "Cc1ccsc1/C=N/NC(=O)C(=O)NCC(C)C", + "scaffold_smiles": "c1ccsc1" + }, + { + "base_idx": 443305, + "atom_indices": [ + 7, + 8, + 16, + 17, + 18, + 19, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "COC(=O)C1=C(C(=O)OC)[C@@H]2C(C)=CC(C)=CN2C(C(=O)OC)=C1C(=O)OC", + "scaffold_smiles": "C1=CC2C=CC=CN2C=C1" + }, + { + "base_idx": 181930, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "Cc1coc2c1c(C)cc1oc(=O)c(CC(=O)N[C@@H](C)C(=O)O)c(C)c12", + "scaffold_smiles": "O=c1ccc2c(ccc3ccoc32)o1" + }, + { + "base_idx": 630021, + "atom_indices": [ + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 15, + 18, + 20, + 23, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "C[C@]12CN3CCN(C[C@H](C3)[C@]1(O)CN1CCCCC1)C2", + "scaffold_smiles": "C1CCN(CC2C3CN4CCN(C3)CC2C4)CC1" + }, + { + "base_idx": 697425, + "atom_indices": [ + 3, + 4, + 5, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 24 + ], + "molecule_smiles": "C=CCn1c(C)c(C(=O)OC)c(/C=C2/C=CC=[S+]2)c1[O-]", + "scaffold_smiles": "C1=CC(=Cc2cc[nH]c2)[S+]=C1" + }, + { + "base_idx": 44212, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 17, + 18 + ], + "molecule_smiles": "O=C(O)[C@@H]1CS[C@]2(CCSC2)N1", + "scaffold_smiles": "C1CSC2(CCSC2)N1" + }, + { + "base_idx": 629871, + "atom_indices": [ + 1, + 2, + 3, + 6, + 8, + 9, + 10, + 12, + 14, + 16, + 17, + 18, + 21 + ], + "molecule_smiles": "C[C@]12/C(=N/O)[C@H]3[C@@H]4C[C@@H]5[C@H]3/C(=N/O)[C@H]1[C@@H]5[C@@H]42", + "scaffold_smiles": "N=C1C2C3CC4C2C(=N)C2C1C3C42" + }, + { + "base_idx": 288071, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 14, + 16, + 31, + 32, + 34, + 35, + 36, + 39, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "C[C@@H]1C(=O)O[C@@H]2C[C@]34[C@H]5C[C@@H](C(C)(C)C)[C@@]36[C@H](OC(=O)[C@@H]6O)O[C@]4(C(=O)O5)[C@@]12O", + "scaffold_smiles": "O=C1CC2C(CC34C5CCC36CC(=O)OC6OC24C(=O)O5)O1" + }, + { + "base_idx": 634945, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CC/C(=N/O)c1cnnc(SC)n1", + "scaffold_smiles": "c1cnncn1" + }, + { + "base_idx": 129204, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14 + ], + "molecule_smiles": "Nc1nc2ncc(=O)[nH]c2c(=O)[nH]1", + "scaffold_smiles": "O=c1cnc2nc[nH]c(=O)c2[nH]1" + }, + { + "base_idx": 135990, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 42, + 43, + 44 + ], + "molecule_smiles": "NC(=O)c1ccsc1NC(=O)c1ccc(S(=O)(=O)N2CCCc3ccccc32)cc1", + "scaffold_smiles": "O=C(Nc1cccs1)c1ccc(S(=O)(=O)N2CCCc3ccccc32)cc1" + }, + { + "base_idx": 296805, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 25, + 28, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "CS(=O)(=O)N(C[C@H](O)Cn1c2ccccc2c2ccccc21)c1cccc2ccccc12", + "scaffold_smiles": "c1ccc2c(NCCCn3c4ccccc4c4ccccc43)cccc2c1" + }, + { + "base_idx": 286512, + "atom_indices": [ + 4, + 5, + 6, + 8, + 9, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 23, + 28, + 29, + 32, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "C[C@]12CC[C@H](O)C[C@H]1CC[C@@H]1[C@@H]2CC[C@]2(C)[C@@](O)(c3ccoc3)CC[C@]12O", + "scaffold_smiles": "c1cc(C2CCC3C2CCC2C4CCCCC4CCC23)co1" + }, + { + "base_idx": 616656, + "atom_indices": [ + 1, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 22, + 34 + ], + "molecule_smiles": "C[C@H]1Cn2c(nnc2[C@]23C[C@H]4C[C@H](C[C@H](C4)C2)C3)S1", + "scaffold_smiles": "C1Cn2c(nnc2C23CC4CC(CC(C4)C2)C3)S1" + }, + { + "base_idx": 395563, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "COc1cc2c3c(c1)/C(=C1/SC(=S)NC1=O)C(=O)N3C(C)(C)C=C2C", + "scaffold_smiles": "O=C1NC(=S)SC1=C1C(=O)N2CC=Cc3cccc1c32" + }, + { + "base_idx": 467363, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CCn1nc(C)c([C@@H]2C(C#N)=C(N)Oc3n[nH]c(C)c32)c1C", + "scaffold_smiles": "C1=CC(c2cn[nH]c2)c2c[nH]nc2O1" + }, + { + "base_idx": 271849, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 11, + 12, + 13, + 14, + 15, + 17 + ], + "molecule_smiles": "Cc1cc(C(F)(F)F)n(C2=N/C(=C(/[O-])NN)C=[S+]2)n1", + "scaffold_smiles": "C=C1C=[S+]C(n2cccn2)=N1" + }, + { + "base_idx": 168878, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "Cc1noc(C)c1CSc1ncnc2sc(C)c(C)c12", + "scaffold_smiles": "c1nc(SCc2cnoc2)c2ccsc2n1" + }, + { + "base_idx": 653551, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 17, + 27, + 28, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 47, + 48, + 49, + 50, + 51, + 52 + ], + "molecule_smiles": "CC(C)CO[P@@](=O)(c1ccc(N(C)C)cc1)[C@@H](Nc1ccccc1)c1ccccc1", + "scaffold_smiles": "O=[PH](c1ccccc1)C(Nc1ccccc1)c1ccccc1" + }, + { + "base_idx": 705962, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 21, + 22, + 24, + 30, + 31, + 33, + 35, + 36, + 38, + 47, + 48, + 49 + ], + "molecule_smiles": "Brc1cc2c(cc1/C=N/Nc1nc(N3CCOCC3)nc(N3CCOCC3)n1)OCO2", + "scaffold_smiles": "C(=NNc1nc(N2CCOCC2)nc(N2CCOCC2)n1)c1ccc2c(c1)OCO2" + }, + { + "base_idx": 30, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 13, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "c1nc2sc3c(c2c2nnnn12)CCC3", + "scaffold_smiles": "c1nc2sc3c(c2c2nnnn12)CCC3" + }, + { + "base_idx": 627438, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 25, + 27, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "CC1=Nc2c/c(=C(\\[O-])NCc3ccco3)ccc2=[S+]c2ccc(C)cc21", + "scaffold_smiles": "C1=Nc2cc(=CNCc3ccco3)ccc2=[S+]c2ccccc21" + }, + { + "base_idx": 631413, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CC1(C)NC(=O)N(C[C@H](O)CN2C(=O)CCC2=O)C1=O", + "scaffold_smiles": "O=C1CCC(=O)N1CCCN1C(=O)CNC1=O" + }, + { + "base_idx": 603079, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 22, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "CC[C@H]1[C@@H](CC)/C=C2/C(=O)OC(=O)[C@H]2CC2=C(C(=O)OC2=O)[C@H]1O", + "scaffold_smiles": "O=C1OC(=O)C2CC3=C(CCCC=C12)C(=O)OC3=O" + }, + { + "base_idx": 48326, + "atom_indices": [ + 5, + 6, + 9, + 10, + 11, + 12, + 13 + ], + "molecule_smiles": "O=C(O)C[C@H]1CCS(=O)(=O)C1", + "scaffold_smiles": "O=S1(=O)CCCC1" + }, + { + "base_idx": 538554, + "atom_indices": [ + 8, + 10, + 11, + 12, + 13, + 21, + 34, + 35 + ], + "molecule_smiles": "CCOC(=O)C[C@@H]1[C@H]2[C@H](C(=O)OCC)[C@@H](C(=O)OC)ON2O[C@H]1C(=O)OCC", + "scaffold_smiles": "C1CC2CCON2O1" + }, + { + "base_idx": 414265, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 20, + 22, + 25, + 27, + 29, + 31, + 34, + 38, + 39, + 40, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59 + ], + "molecule_smiles": "COc1c(N2CCN(C(=S)N[C@@H]3C[C@H]4CC[C@@H]3C4)[C@@H](C)C2)c(F)cc2c(=O)c(C(=O)O)cn(C3CC3)c12", + "scaffold_smiles": "O=c1ccn(C2CC2)c2cc(N3CCN(C(=S)NC4CC5CCC4C5)CC3)ccc12" + }, + { + "base_idx": 386625, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "COc1ccc(/C(C)=N/OC/C(O)=C(\\C#N)c2nc3ccccc3[nH]2)cc1OC", + "scaffold_smiles": "C(=Cc1nc2ccccc2[nH]1)CON=Cc1ccccc1" + }, + { + "base_idx": 696390, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 22, + 31, + 46, + 54 + ], + "molecule_smiles": "C=CCN1c2ccccc2[C@]23C[C@@H](C(=O)OC)N(C(=O)NCCC)C2=NC(C(=O)OC)=C(C(=O)OC)[C@H]13", + "scaffold_smiles": "C1=CC2Nc3ccccc3C23CCNC3=N1" + }, + { + "base_idx": 180933, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 27 + ], + "molecule_smiles": "Cc1nc([C@@H](C)NC(=O)CCc2nnc(-c3ccsc3)o2)cs1", + "scaffold_smiles": "O=C(CCc1nnc(-c2ccsc2)o1)NCc1cscn1" + }, + { + "base_idx": 604288, + "atom_indices": [ + 5, + 6, + 13, + 14, + 18 + ], + "molecule_smiles": "CC[C@@]1(C)SC(N)=NN1C(C)=O", + "scaffold_smiles": "C1=NNCS1" + }, + { + "base_idx": 600332, + "atom_indices": [ + 3, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15 + ], + "molecule_smiles": "CCCC(O)=C1C(=O)NC(=O)NC1=O", + "scaffold_smiles": "C=C1C(=O)NC(=O)NC1=O" + }, + { + "base_idx": 698773, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 38, + 39 + ], + "molecule_smiles": "C=C1CC[C@]2(O)[C@@H]3Cc4ccc(O)c5c4[C@]2(CCN3CC2CC2)[C@@H]1O5", + "scaffold_smiles": "C=C1CCC2C3Cc4cccc5c4C2(CCN3CC2CC2)C1O5" + }, + { + "base_idx": 136308, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "NC(=O)C1=NN=[S+]C1=c1ccc(=c2snn/c2=C(\\N)[O-])cc1", + "scaffold_smiles": "C=c1nnsc1=c1ccc(=C2C=NN=[S+]2)cc1" + }, + { + "base_idx": 652135, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CC(C)Cn1c(-c2nn(C)cc2Cl)n[nH]c1=S", + "scaffold_smiles": "S=c1[nH]nc(-c2cc[nH]n2)[nH]1" + }, + { + "base_idx": 654348, + "atom_indices": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 29, + 30, + 31 + ], + "molecule_smiles": "CC(C)CNC(=O)CN1c2cccc3cccc(c23)S1(=O)=O", + "scaffold_smiles": "O=S1(=O)Nc2cccc3cccc1c23" + }, + { + "base_idx": 2127, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28, + 31 + ], + "molecule_smiles": "C1=C(c2ccccc2)N(Cc2ccccc2)C2=[S+]CCCN12", + "scaffold_smiles": "C1=C(c2ccccc2)N(Cc2ccccc2)C2=[S+]CCCN12" + }, + { + "base_idx": 286847, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "C[C@H]1[C@@H](O)O[C@@H]2O[C@@]3(C)CC[C@H]4[C@H](C)CC[C@@H]1[C@@]24OO3", + "scaffold_smiles": "C1CC2CCOC3OC4CCC(C1)C23OO4" + }, + { + "base_idx": 673880, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 29 + ], + "molecule_smiles": "CC(=O)Nc1nnc(-c2ccc3c(c2)CCCC3)o1", + "scaffold_smiles": "c1nnc(-c2ccc3c(c2)CCCC3)o1" + }, + { + "base_idx": 454070, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "CN(Cc1nc(-c2cnccn2)no1)Cc1n[nH]c2c1CCCCC2", + "scaffold_smiles": "c1cnc(-c2noc(CNCc3n[nH]c4c3CCCCC4)n2)cn1" + }, + { + "base_idx": 558713, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCN(CC)c1cc2nonc2c2nonc12", + "scaffold_smiles": "c1cc2nonc2c2nonc12" + }, + { + "base_idx": 263910, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 26 + ], + "molecule_smiles": "CC1=CC(C)=NC2=[S+]C[C@@]([O-])(NC3=NC(=O)[C@@H](C)S3)N12", + "scaffold_smiles": "O=C1CSC(NC2C[S+]=C3N=CC=CN32)=N1" + }, + { + "base_idx": 208154, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CC1=CC=C2N=[S+]N=C2C1=[S@@](=O)([O-])NCc1ccncc1", + "scaffold_smiles": "O=[SH](NCc1ccncc1)=C1C=CC=C2N=[S+]N=C21" + }, + { + "base_idx": 585194, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 40, + 41, + 42 + ], + "molecule_smiles": "CCCCc1cc2c(cn1)[C@@]13OC(=O)C4=CN=C5C=CC=C[C@]5(O[C@]5(CC)OC(=C2)[C@@]1(C)O5)N43", + "scaffold_smiles": "O=C1OC23c4cnccc4C=C4OC(OC42)OC24C=CC=CC2=NC=C1N43" + }, + { + "base_idx": 616647, + "atom_indices": [ + 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 + ], + "molecule_smiles": "C[C@H]1N=C2c3ccccc3N=C(SCc3cc(=O)n4cc(Cl)ccc4n3)N2C1=O", + "scaffold_smiles": "O=C1CN=C2c3ccccc3N=C(SCc3cc(=O)n4ccccc4n3)N12" + }, + { + "base_idx": 451293, + "atom_indices": [ + 1, + 2, + 3, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24 + ], + "molecule_smiles": "CN1CCC[C@H]1CCN[C@H]1[C@@H]2C[C@H]3C[C@@H](C2)C[C@@H]1C3", + "scaffold_smiles": "C1CNC(CCNC2C3CC4CC(C3)CC2C4)C1" + }, + { + "base_idx": 516206, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 14, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 35, + 36, + 37, + 38, + 41 + ], + "molecule_smiles": "CCO[C@]1(C)OCC(=O)[C@]2(CC[C@@H]3[C@@H]4CCC5=CC(=O)C=C[C@]5(C)[C@H]4C(=O)C[C@@]32C)O1", + "scaffold_smiles": "O=C1C=CC2C(=C1)CCC1C3CCC4(OCOCC4=O)C3CC(=O)C21" + }, + { + "base_idx": 580465, + "atom_indices": [ + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 30, + 31, + 35, + 36, + 38 + ], + "molecule_smiles": "CCCN1C[C@@H]2CCC[C@@]3(C1)C(=O)NC1(CCCCC1)N=C23", + "scaffold_smiles": "O=C1NC2(CCCCC2)N=C2C3CCCC12CNC3" + }, + { + "base_idx": 628943, + "atom_indices": [ + 1, + 2, + 16, + 17, + 18, + 19, + 20, + 21, + 23, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "CC1=C(C(=O)OC(C)C)[C@@H](c2c[nH]c3ccccc23)n2ncnc2N1", + "scaffold_smiles": "C1=CC(c2c[nH]c3ccccc23)n2ncnc2N1" + }, + { + "base_idx": 457961, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 15, + 16, + 17, + 18, + 20 + ], + "molecule_smiles": "CN(C)c1nc(N)nc(/C(C#N)=C/[C@H]2C[C@@H]3C=C[C@H]2C3)n1", + "scaffold_smiles": "C(=CC1CC2C=CC1C2)c1ncncn1" + }, + { + "base_idx": 611074, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 31, + 33, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "CCC(C)(C)n1nnnc1[C@H](c1ccc2ncccc2c1)N1CCN(c2nc3ccccc3s2)CC1", + "scaffold_smiles": "c1cnc2ccc(C(c3nnn[nH]3)N3CCN(c4nc5ccccc5s4)CC3)cc2c1" + }, + { + "base_idx": 688916, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17, + 18, + 25 + ], + "molecule_smiles": "C=CCn1c(SCC(=O)OC)nc2[s+]c3c(c-2c1[O-])CCN(C)C3", + "scaffold_smiles": "c1nc2[s+]c3c(c-2c[nH]1)CCNC3" + }, + { + "base_idx": 286001, + "atom_indices": [ + 4, + 5, + 6, + 8, + 11, + 16, + 19, + 21, + 24, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 40, + 41, + 42, + 43, + 45, + 50, + 51, + 63, + 64 + ], + "molecule_smiles": "C[C@H](c1ccc2ccc(O[C@H]3CC[C@@H](C(F)(F)F)CC3)c(C(F)(F)F)c2c1)N1[C@@H]2CC[C@H]1C[C@@H](C(=O)O)C2", + "scaffold_smiles": "c1cc2ccc(OC3CCCCC3)cc2cc1CN1C2CCCC1CC2" + }, + { + "base_idx": 514410, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 19, + 20, + 21, + 22, + 23, + 24, + 27 + ], + "molecule_smiles": "CCOC(=O)c1sc(S(C)(=O)=O)c2c1CCc1conc1-2", + "scaffold_smiles": "c1scc2c1CCc1conc1-2" + }, + { + "base_idx": 496534, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "CCOc1ccc(Oc2nc(C)cc(OC)n2)nn1", + "scaffold_smiles": "c1cnc(Oc2cccnn2)nc1" + }, + { + "base_idx": 521587, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 28, + 29, + 30 + ], + "molecule_smiles": "CCOC(=O)c1cc2sccc2n1CC(=O)OC(C)C", + "scaffold_smiles": "c1cc2sccc2[nH]1" + }, + { + "base_idx": 451223, + "atom_indices": [ + 1, + 2, + 4, + 5, + 6, + 7, + 9, + 12, + 16, + 17, + 21, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "CN1[C@@H]2C=C3C(C#N)=C(N)C(C#N)(C#N)[C@@H](c4ccsc4)[C@@H]3[C@H]1CC2", + "scaffold_smiles": "C1=CC2=CC3CCC(N3)C2C(c2ccsc2)C1" + }, + { + "base_idx": 96813, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "O=C(C/[S+]=c1/ccccn1[O-])c1cc2ccccc2o1", + "scaffold_smiles": "O=C(C[S+]=c1cccc[nH]1)c1cc2ccccc2o1" + }, + { + "base_idx": 430516, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 33, + 38, + 39, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 57 + ], + "molecule_smiles": "COC(=O)c1cccc([C@]23C[C@]4(C2)[C@H](CN(C)[C@@H]4c2ccccc2)[C@H]3c2ccccc2)c1", + "scaffold_smiles": "c1ccc(C2C3CNC(c4ccccc4)C34CC2(c2ccccc2)C4)cc1" + }, + { + "base_idx": 521257, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "CCOC(=O)c1cc(C(C)=O)n2ccccc12", + "scaffold_smiles": "c1ccn2cccc2c1" + }, + { + "base_idx": 471150, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 17, + 18, + 19 + ], + "molecule_smiles": "CCn1c(O)c(/C=N/n2cnnc2)sc1=S", + "scaffold_smiles": "S=c1[nH]cc(C=Nn2cnnc2)s1" + }, + { + "base_idx": 516129, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 29, + 31, + 32, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "CCOC1=C/C(=C/c2c(N)n3nc(CC(=O)N4CCCC4)sc3nc2=O)C=CC1=O", + "scaffold_smiles": "O=C1C=CC(=Cc2cn3nc(CC(=O)N4CCCC4)sc3nc2=O)C=C1" + }, + { + "base_idx": 29331, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 22, + 23 + ], + "molecule_smiles": "O=C1CCC(C(=O)Nc2nnc(C3CC3)s2)=NN1", + "scaffold_smiles": "O=C1CCC(C(=O)Nc2nnc(C3CC3)s2)=NN1" + }, + { + "base_idx": 627176, + "atom_indices": [ + 4, + 5, + 8, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 30 + ], + "molecule_smiles": "C[C@@H]1C/C(=N\\N=C2/NC(=O)CS2)CC(C)(C)C1", + "scaffold_smiles": "O=C1CSC(=NN=C2CCCCC2)N1" + }, + { + "base_idx": 522604, + "atom_indices": [ + 7, + 8, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26 + ], + "molecule_smiles": "CCOC(=O)C1=C2CCCNC([O-])=C2[S+]=C1NC(C)=O", + "scaffold_smiles": "C1=[S+]C2=CNCCCC2=C1" + }, + { + "base_idx": 108115, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "O=C(CNC(=O)OCc1ccccc1)NCC(=O)Nc1ccc(Br)cc1C(=O)c1ccccc1", + "scaffold_smiles": "O=C(CNC(=O)OCc1ccccc1)NCC(=O)Nc1ccccc1C(=O)c1ccccc1" + }, + { + "base_idx": 627915, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 36, + 37, + 39, + 40 + ], + "molecule_smiles": "[H]/N=C1C(=C/c2cccn2CCOc2ccccc2)/C(=O)N=C2C=C(C)ON2/1", + "scaffold_smiles": "N=C1C(=Cc2cccn2CCOc2ccccc2)C(=O)N=C2C=CON12" + }, + { + "base_idx": 27693, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 25, + 26, + 27 + ], + "molecule_smiles": "O=C1C2=Cc3c([nH]c(=O)[nH]c3=O)O[C@@H]2Oc2ccc(O)cc21", + "scaffold_smiles": "O=C1C2=Cc3c([nH]c(=O)[nH]c3=O)OC2Oc2ccccc21" + }, + { + "base_idx": 642022, + "atom_indices": [ + 13, + 14, + 15, + 16, + 17, + 18, + 20 + ], + "molecule_smiles": "C[C@H](CN1CCCCCC1)C(=O)NN", + "scaffold_smiles": "C1CCCNCC1" + }, + { + "base_idx": 31400, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28, + 34, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "O=C1C[C@@H]2N3CCN[C@]34O[C@]2(OC2=C4CC/C2=C\\c2ccccc2)N1c1ccccc1", + "scaffold_smiles": "O=C1CC2N3CCNC34OC2(OC2=C4CCC2=Cc2ccccc2)N1c1ccccc1" + }, + { + "base_idx": 4658, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "molecule_smiles": "[H]/N=c1\\ncn2c3c1ncn3C=C[C@H]2CO", + "scaffold_smiles": "N=c1ncn2c3c1ncn3C=CC2" + }, + { + "base_idx": 25031, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 6 + ], + "molecule_smiles": "O=C1O[C@H]([C@@H](O)CO)C([O-])=C1O", + "scaffold_smiles": "O=C1C=CCO1" + }, + { + "base_idx": 153023, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "Cn1cnnc1/[S+]=C1/C=C/C(=C/C(CC(=O)O)=C2/N=c3ccccc3=[S+]2)C=C1N([O-])[O-]", + "scaffold_smiles": "C1=CC(=[S+]c2nnc[nH]2)C=CC1=CC=C1N=c2ccccc2=[S+]1" + }, + { + "base_idx": 426802, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55 + ], + "molecule_smiles": "COC/C=C/C1=CC2=CC(=O)[C@@](C)(OC(=O)c3cnc4ccccc4n3)C(=O)C2=CN1CCCOC(C)C", + "scaffold_smiles": "O=C(OC1C(=O)C=C2C=CNC=C2C1=O)c1cnc2ccccc2n1" + }, + { + "base_idx": 258444, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 29, + 33 + ], + "molecule_smiles": "Cc1cc(NS(=O)(=O)c2ccc(Nc3nc(-c4sc(N)nc4C)cs3)cc2)no1", + "scaffold_smiles": "O=S(=O)(Nc1ccon1)c1ccc(Nc2nc(-c3cncs3)cs2)cc1" + }, + { + "base_idx": 701043, + "atom_indices": [ + 0, + 3, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 33, + 36 + ], + "molecule_smiles": "C1CCC(Nc2nc3nonc3nc2N2CCSCC2)C1", + "scaffold_smiles": "C1CCC(Nc2nc3nonc3nc2N2CCSCC2)C1" + }, + { + "base_idx": 200694, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 25, + 26, + 27, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "Cc1cccc(N([C@@H]2C=CS(=O)(=O)C2)S(=O)(=O)c2ccccc2)c1", + "scaffold_smiles": "O=S1(=O)C=CC(N(c2ccccc2)S(=O)(=O)c2ccccc2)C1" + }, + { + "base_idx": 615169, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 10, + 13, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 27 + ], + "molecule_smiles": "C/C=C1\\[C@H]2C=C(C)C[C@]1(N)c1ccc(=O)[nH]c1C2", + "scaffold_smiles": "C=C1C2C=CCC1c1ccc(=O)[nH]c1C2" + }, + { + "base_idx": 551865, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 23, + 24, + 26 + ], + "molecule_smiles": "CCN1CCN(c2oc(-c3ccco3)nc2C#N)CC1", + "scaffold_smiles": "c1coc(-c2ncc(N3CCNCC3)o2)c1" + }, + { + "base_idx": 665052, + "atom_indices": [ + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 27, + 28, + 29, + 30, + 31, + 32, + 41, + 42 + ], + "molecule_smiles": "CC(C)(C)OC(=O)Nc1ncnc2c1ncn2[C@@H]1O[C@H](CO)[C@H]2OC(C)(C)O[C@H]21", + "scaffold_smiles": "c1ncc2ncn(C3OCC4OCOC43)c2n1" + }, + { + "base_idx": 645280, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 27, + 30, + 37, + 38, + 39, + 41 + ], + "molecule_smiles": "CC(C)[C@]12O[C@H]1[C@@H]1O[C@@]13[C@@]1(C)CCC4=C(COC4=O)[C@@H]1C[C@@H]1O[C@@]13C2=O", + "scaffold_smiles": "O=C1OCC2=C1CCC1C2CC2OC23C(=O)C2OC2C2OC123" + }, + { + "base_idx": 626745, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 11, + 13, + 14, + 15, + 22 + ], + "molecule_smiles": "CC1=NOC2(C1)/C(=N/O)CCC/C2=N\\O", + "scaffold_smiles": "N=C1CCCC(=N)C12CC=NO2" + }, + { + "base_idx": 572823, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 11, + 13, + 14, + 15, + 16, + 18, + 21, + 22, + 25, + 28, + 31, + 33 + ], + "molecule_smiles": "CCCC1=C/C(=C(\\[O-])NNC(=S)NC[C@H]2CCCO2)C=[S+]1", + "scaffold_smiles": "S=C(NCC1CCCO1)NNC=C1C=C[S+]=C1" + }, + { + "base_idx": 647685, + "atom_indices": [ + 10, + 11, + 12, + 13, + 14, + 15, + 32, + 35, + 36, + 41, + 42, + 43, + 45, + 48, + 50, + 51, + 54, + 59, + 60 + ], + "molecule_smiles": "CC(C)[C@H](NC(=O)[C@@H]1CSCN1C(=O)OC(C)(C)C)C(=O)NC[C@H]1CC[C@@H](C(=O)O)CC1", + "scaffold_smiles": "O=C(CNC(=O)C1CSCN1)NCC1CCCCC1" + }, + { + "base_idx": 696790, + "atom_indices": [ + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 19, + 20, + 22, + 25, + 26, + 27, + 32, + 33, + 35, + 37 + ], + "molecule_smiles": "C=CCN1C(=O)C[S+]=c2cc/c(=C(/[O-])N3CCC4(CC3)OCCO4)cc21", + "scaffold_smiles": "O=C1C[S+]=c2ccc(=CN3CCC4(CC3)OCCO4)cc2N1" + }, + { + "base_idx": 390788, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "COc1ccc(-c2nn(-c3ccccc3)cc2C(=O)n2nnc3ccccc32)cc1", + "scaffold_smiles": "O=C(c1cn(-c2ccccc2)nc1-c1ccccc1)n1nnc2ccccc21" + }, + { + "base_idx": 169982, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 25, + 26, + 32, + 33, + 34, + 35, + 36, + 38 + ], + "molecule_smiles": "Cc1noc(C)c1-c1ccc(C2=C/C(=C(\\N)[O-])C(NC(N)=O)=[S+]2)c(O[C@H]2CCNC2)c1", + "scaffold_smiles": "C=C1C=[S+]C(c2ccc(-c3cnoc3)cc2OC2CCNC2)=C1" + }, + { + "base_idx": 673006, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "[H]/N=c1\\c2sc3nc(C)c(C(C)=O)c(-c4ccc(Cl)cc4)c3c2ncn1N", + "scaffold_smiles": "N=c1[nH]cnc2c1sc1nccc(-c3ccccc3)c12" + }, + { + "base_idx": 699974, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 20, + 23, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 46, + 47, + 51, + 52, + 53 + ], + "molecule_smiles": "C1=C\\COCc2cc(ccc2OCCN2CCCC2)Nc2nccc(n2)-c2cccc(c2)COC/1", + "scaffold_smiles": "C1=CCOCc2cc(ccc2OCCN2CCCC2)Nc2nccc(n2)-c2cccc(c2)COC1" + }, + { + "base_idx": 179376, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "Cc1nc(C)c2c(c1C(F)(F)F)-c1ccccc1C2(F)F", + "scaffold_smiles": "c1ccc2c(c1)Cc1cnccc1-2" + }, + { + "base_idx": 428385, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 33, + 35, + 36 + ], + "molecule_smiles": "COC(=O)C1=C(C)/C(=C(/[O-])OC)C(NC(=S)NNC(=O)c2c(Br)c(C)nn2C)=[S+]1", + "scaffold_smiles": "C=C1C=C[S+]=C1NC(=S)NNC(=O)c1ccn[nH]1" + }, + { + "base_idx": 138184, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 13, + 16 + ], + "molecule_smiles": "NC(=O)[C@@H]1CC[C@@H]2CN1C(=O)N2OS(=O)(=O)[O-]", + "scaffold_smiles": "O=C1NC2CCCN1C2" + }, + { + "base_idx": 682632, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CC(=O)N1N=C(c2cccs2)C[C@@H]1c1cccs1", + "scaffold_smiles": "c1csc(C2=NNC(c3cccs3)C2)c1" + }, + { + "base_idx": 542344, + "atom_indices": [ + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 26, + 27, + 28, + 29, + 42, + 43, + 44, + 45, + 46, + 47 + ], + "molecule_smiles": "CCOC(=O)C1=C(N)N2C(=O)/C(=C/c3ccco3)[S+]=C2/C(=C(\\[O-])OC(C)C)[C@H]1c1ccco1", + "scaffold_smiles": "C=C1C2=[S+]C(=Cc3ccco3)C(=O)N2C=CC1c1ccco1" + }, + { + "base_idx": 511052, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "CCOc1cc(/C=N/NC(=O)c2cccnc2)ccc1OC(=O)c1ccco1", + "scaffold_smiles": "O=C(NN=Cc1ccc(OC(=O)c2ccco2)cc1)c1cccnc1" + }, + { + "base_idx": 177746, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "Cc1nc(Nc2nc3ccccc3o2)nc2c1CCC2", + "scaffold_smiles": "c1ccc2oc(Nc3ncc4c(n3)CCC4)nc2c1" + }, + { + "base_idx": 348525, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 45, + 46, + 51, + 53 + ], + "molecule_smiles": "COc1ccc(/N=C/C2=C(Nc3ccc(OC)cc3)c3c(C)nn(-c4ccccn4)c3CC2)cc1", + "scaffold_smiles": "C(=Nc1ccccc1)C1=C(Nc2ccccc2)c2cnn(-c3ccccn3)c2CC1" + }, + { + "base_idx": 437993, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12 + ], + "molecule_smiles": "COC(=O)Cc1c[s+]c(N)[nH]1", + "scaffold_smiles": "c1c[s+]c[nH]1" + }, + { + "base_idx": 177084, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 17 + ], + "molecule_smiles": "Cc1nc2cc3sc(C)nc3cc2s1", + "scaffold_smiles": "c1nc2cc3scnc3cc2s1" + }, + { + "base_idx": 484424, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CCc1c(C)c2nncn2[nH]c1=O", + "scaffold_smiles": "O=c1ccc2nncn2[nH]1" + }, + { + "base_idx": 170570, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 35, + 37, + 38, + 47 + ], + "molecule_smiles": "Cc1nnc(SCC2=C(C(=O)O)N3C(=O)[C@H](NC(=O)Cn4nc(C)c(Cl)c4C)[C@@H]3SC2)s1", + "scaffold_smiles": "O=C(Cn1cccn1)NC1C(=O)N2C=C(CSc3nncs3)CSC12" + }, + { + "base_idx": 169279, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "molecule_smiles": "Cc1nnsc1[S@+]([O-])c1ccc(Cl)c(Cl)c1", + "scaffold_smiles": "c1ccc([S+]c2cnns2)cc1" + }, + { + "base_idx": 35871, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 8, + 9, + 10, + 12, + 21, + 22, + 23, + 24, + 25, + 26, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 38, + 39, + 40, + 42, + 44 + ], + "molecule_smiles": "O=C(c1cccc(N2C(=O)[C@@H]3[C@H](C2=O)[C@H]2C=C[C@@H]3C2)c1)N1CCCCCC1", + "scaffold_smiles": "O=C(c1cccc(N2C(=O)C3C4C=CC(C4)C3C2=O)c1)N1CCCCCC1" + }, + { + "base_idx": 634024, + "atom_indices": [ + 4, + 9, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24 + ], + "molecule_smiles": "CC1(C)[C@H]2CC[C@@]13CS(=O)(=O)N[C@H]3C2", + "scaffold_smiles": "O=S1(=O)CC23CCC(CC2N1)C3" + }, + { + "base_idx": 476491, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 19, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 49 + ], + "molecule_smiles": "CCc1nc2ccccc2c(C(=O)OCC(=O)Nc2cccc(S(=O)(=O)NC3=NCCCCC3)c2)c1C", + "scaffold_smiles": "O=C(COC(=O)c1ccnc2ccccc12)Nc1cccc(S(=O)(=O)NC2=NCCCCC2)c1" + }, + { + "base_idx": 20413, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 6, + 8, + 9, + 11, + 17, + 18, + 19, + 20, + 21, + 22, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "O=S(=O)(c1cccc(-c2nn3c(-c4ccncc4)nnc3s2)c1)N1CCOCC1", + "scaffold_smiles": "O=S(=O)(c1cccc(-c2nn3c(-c4ccncc4)nnc3s2)c1)N1CCOCC1" + }, + { + "base_idx": 504457, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 37, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "CCOc1ccc([C@H]2c3c(oc4ccccc4c3=O)C(=O)N2CCCN2CCOCC2)cc1OCC", + "scaffold_smiles": "O=C1c2oc3ccccc3c(=O)c2C(c2ccccc2)N1CCCN1CCOCC1" + }, + { + "base_idx": 949, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 29, + 31 + ], + "molecule_smiles": "c1ccc2c(c1)OC[C@H](c1nnc3n1-c1ccccc1SC3)O2", + "scaffold_smiles": "c1ccc2c(c1)OCC(c1nnc3n1-c1ccccc1SC3)O2" + }, + { + "base_idx": 438073, + "atom_indices": [ + 5, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 31 + ], + "molecule_smiles": "CO[C@@]1([O-])C[S+]=C2N=C(N(C)C)N=C(NC(C)(C)C)N21", + "scaffold_smiles": "C1=NC=NC2=[S+]CCN12" + }, + { + "base_idx": 570449, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 31, + 32, + 33, + 34, + 44, + 45, + 46 + ], + "molecule_smiles": "CCCn1c(SCc2nc(N)nc(N3CCCCC3)n2)nc2c1c(=O)n(C)c(=O)n2C", + "scaffold_smiles": "O=c1[nH]c(=O)c2[nH]c(SCc3ncnc(N4CCCCC4)n3)nc2[nH]1" + }, + { + "base_idx": 268044, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 23, + 26, + 27, + 29 + ], + "molecule_smiles": "CC1=CN2c3[nH]c([O-])[s+]c3[C@@H]3OC(c4cccnc4)=NN3[C@@H]2C(C)=C1", + "scaffold_smiles": "C1=CC2N(C=C1)c1[nH]c[s+]c1C1OC(c3cccnc3)=NN12" + }, + { + "base_idx": 535729, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25 + ], + "molecule_smiles": "CCOC(=O)CSC1=NN2CCS(=O)(=O)N=C2S1", + "scaffold_smiles": "O=S1(=O)CCN2N=CSC2=N1" + }, + { + "base_idx": 208777, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 21, + 41 + ], + "molecule_smiles": "Cc1ccc2[nH]c(N(CCN(C)C)C(=O)C3=COCCO3)[s+]c2c1", + "scaffold_smiles": "O=C(Nc1[nH]c2ccccc2[s+]1)C1=COCCO1" + }, + { + "base_idx": 136788, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "molecule_smiles": "NC(=O)c1c[nH]c(C2CC2)n1", + "scaffold_smiles": "c1c[nH]c(C2CC2)n1" + }, + { + "base_idx": 658005, + "atom_indices": [ + 12, + 13, + 14, + 18, + 19, + 20 + ], + "molecule_smiles": "CC(C)CC1C(C#N)=C(N)SC(N)=C1C#N", + "scaffold_smiles": "C1=CSC=CC1" + }, + { + "base_idx": 612714, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 17, + 25 + ], + "molecule_smiles": "CCC(=O)NC1=[S+]C2=C(CCCCC2)/C1=C(/[O-])N(C)C", + "scaffold_smiles": "C=C1C=[S+]C2=C1CCCCC2" + }, + { + "base_idx": 631685, + "atom_indices": [ + 1, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 18, + 19, + 24 + ], + "molecule_smiles": "CC1(C)Cc2nc3sc(C(=O)O)c(N)c3cc2CS1", + "scaffold_smiles": "c1cc2cc3c(nc2s1)CCSC3" + }, + { + "base_idx": 442135, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 20 + ], + "molecule_smiles": "COC(=O)/C=c1/sc2n(c1=O)CCN=2", + "scaffold_smiles": "C=c1sc2n(c1=O)CCN=2" + }, + { + "base_idx": 597219, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "CCCCC1=NC2(CCCC2)C(=O)N1Cc1ccc(-c2ccccc2-c2nn[nH]n2)cc1", + "scaffold_smiles": "O=C1N(Cc2ccc(-c3ccccc3-c3nn[nH]n3)cc2)C=NC12CCCC2" + }, + { + "base_idx": 588, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 23, + 24 + ], + "molecule_smiles": "c1cn(CC2(Cn3ccnc3)COC2)cn1", + "scaffold_smiles": "c1cn(CC2(Cn3ccnc3)COC2)cn1" + }, + { + "base_idx": 618920, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 14, + 15, + 16, + 17, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 34, + 37, + 43, + 44 + ], + "molecule_smiles": "C[C@H]1CCC2=[S+]/C(=C(/[O-])NN=C3CCC(c4ccccc4)CC3)C=C2C1", + "scaffold_smiles": "C(NN=C1CCC(c2ccccc2)CC1)=C1C=C2CCCCC2=[S+]1" + }, + { + "base_idx": 543831, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 24, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)NC2=C(C(=O)CC(C)(C)C2)[C@H]1c1ccc2c(c1)CCCN2C", + "scaffold_smiles": "O=C1CCCC2=C1C(c1ccc3c(c1)CCCN3)C=CN2" + }, + { + "base_idx": 244927, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 18, + 19, + 20, + 23, + 31, + 32, + 33, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "Cc1ccc([C@H](C(=O)NC2CCCCC2)N(C(=O)c2csnn2)C2CCCC2)o1", + "scaffold_smiles": "O=C(NC1CCCCC1)C(c1ccco1)N(C(=O)c1csnn1)C1CCCC1" + }, + { + "base_idx": 691816, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "C=CCSc1nnc2c(n1)OC(C)=Nc1ccc(Br)cc1-2", + "scaffold_smiles": "C1=Nc2ccccc2-c2nncnc2O1" + }, + { + "base_idx": 21439, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 17, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 32, + 33, + 34, + 37 + ], + "molecule_smiles": "O=S(=O)(c1ccc(OC[C@@H]2CO2)cc1)c1ccc(OC[C@@H]2CO2)cc1", + "scaffold_smiles": "O=S(=O)(c1ccc(OCC2CO2)cc1)c1ccc(OCC2CO2)cc1" + }, + { + "base_idx": 517377, + "atom_indices": [ + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 29, + 30, + 31, + 40 + ], + "molecule_smiles": "CCOC(=O)c1oc2nc(C(C)C)c3c(c2c1N)CC(C)(C)OC3", + "scaffold_smiles": "c1cc2c3c(cnc2o1)COCC3" + }, + { + "base_idx": 620524, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 23, + 24, + 25, + 27, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 51, + 52 + ], + "molecule_smiles": "CC1CCN(S(=O)(=O)N2CCC(C(=O)NCCC3=CCCCC3)CC2)CC1", + "scaffold_smiles": "O=C(NCCC1=CCCCC1)C1CCN(S(=O)(=O)N2CCCCC2)CC1" + }, + { + "base_idx": 539291, + "atom_indices": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "CCOC(=O)C[C@H](NS(=O)(=O)c1ccc2[nH]cc(C(=O)O)c(=O)c2c1)c1ccc(OCC)c(OC)c1", + "scaffold_smiles": "O=c1cc[nH]c2ccc(S(=O)(=O)NCc3ccccc3)cc12" + }, + { + "base_idx": 180528, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 17 + ], + "molecule_smiles": "Cc1c[s+]c2c([O-])n3c(nc1-2)CCCCC3", + "scaffold_smiles": "c1cc2nc3n(cc-2[s+]1)CCCCC3" + }, + { + "base_idx": 512980, + "atom_indices": [ + 5, + 6, + 19, + 20, + 21, + 22, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCO[C@@H]1OC(C(=O)O)=C[C@H](c2csc3ccccc23)[C@H]1CCCO", + "scaffold_smiles": "C1=CC(c2csc3ccccc23)CCO1" + }, + { + "base_idx": 283285, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 26, + 27, + 28, + 31, + 36, + 37, + 38 + ], + "molecule_smiles": "Cc1c(/C=C2/C[C@H]3[C@H]4CC=C5C[C@@H](O)CC[C@]5(C)[C@@H]4CC[C@@]3(C)C2=O)cnn1C", + "scaffold_smiles": "O=C1C(=Cc2cn[nH]c2)CC2C1CCC1C3CCCCC3=CCC21" + }, + { + "base_idx": 642818, + "atom_indices": [ + 16, + 18, + 21, + 24, + 26, + 27, + 28, + 29, + 32, + 33, + 34, + 35, + 36, + 39, + 42, + 47, + 48, + 49, + 50, + 51 + ], + "molecule_smiles": "C[C@@H](CCC(=O)O)[C@H]1CC[C@@H]2[C@H]3C(=O)C[C@@H]4CC(=O)CC[C@]4(C)[C@@H]3CC(=O)[C@@]21C", + "scaffold_smiles": "O=C1CCC2C(C1)CC(=O)C1C3CCCC3C(=O)CC21" + }, + { + "base_idx": 446074, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "CNC(=O)c1ccccc1Sc1ccc2c(/C=C/c3ccccn3)n[nH]c2c1", + "scaffold_smiles": "C(=Cc1n[nH]c2cc(Sc3ccccc3)ccc12)c1ccccn1" + }, + { + "base_idx": 10667, + "atom_indices": [ + 4, + 5, + 6, + 9, + 12, + 16, + 17, + 18, + 19, + 20, + 26, + 29, + 32 + ], + "molecule_smiles": "OC[C@@H]1O[C@@H](O[C@H]2[C@@H](O)[C@H](O)[C@H](O)O[C@H]2CO)[C@@H](O)[C@H](O)[C@@H]1O", + "scaffold_smiles": "C1CCC(OC2CCCOC2)OC1" + }, + { + "base_idx": 286829, + "atom_indices": [ + 1, + 2, + 4, + 7, + 8, + 9, + 10, + 11, + 14, + 30 + ], + "molecule_smiles": "C[C@@H]1CC[C@]2(O[C@@H](CCO)C[C@@H](O)[C@@H]2O)O1", + "scaffold_smiles": "C1CCC2(CCCO2)OC1" + }, + { + "base_idx": 143718, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "N#Cc1ccccc1/[S+]=C1C=CC=CC\\1=C(\\[O-])OCC(=O)N[C@H]1CCS(=O)(=O)C1", + "scaffold_smiles": "O=C(COC=C1C=CC=CC1=[S+]c1ccccc1)NC1CCS(=O)(=O)C1" + }, + { + "base_idx": 613230, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CCC(=O)O[C@@]1(C)C(=O)C2=COC(c3ccc(OC)cc3)=CC2=C(Br)C1=O", + "scaffold_smiles": "O=C1C=C2C=C(c3ccccc3)OC=C2C(=O)C1" + }, + { + "base_idx": 446868, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 15, + 16, + 17, + 20, + 21, + 22, + 32, + 33, + 34, + 35, + 36, + 37, + 42, + 43 + ], + "molecule_smiles": "CN1c2ccccc2[C@@H]2NCCCCCCC(=O)OS1(=O)([O-])c1cc(Cl)ccc12", + "scaffold_smiles": "O=C1CCCCCCNC2c3ccccc3N[SH](=O)(O1)c1ccccc12" + }, + { + "base_idx": 694609, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 19, + 21, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "C=CCOCCN1C(=O)[C@@H]2[C@H]3C=C[C@@H]([C@@H]2C1=O)[C@@H]1[C@@H]2C(=O)N(CCOCC=C)C(=O)[C@@H]2[C@H]31", + "scaffold_smiles": "O=C1NC(=O)C2C3C=CC(C12)C1C2C(=O)NC(=O)C2C31" + }, + { + "base_idx": 23900, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 7, + 8, + 9, + 16, + 17, + 19, + 21, + 22, + 23 + ], + "molecule_smiles": "O=P(OCC(F)(F)C(F)F)(N1CCOCC1)N1CCOCC1", + "scaffold_smiles": "O=[PH](N1CCOCC1)N1CCOCC1" + }, + { + "base_idx": 648100, + "atom_indices": [ + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CC(C)OC(=O)c1ccc(S(N)(=O)=O)o1", + "scaffold_smiles": "c1ccoc1" + }, + { + "base_idx": 10403, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8 + ], + "molecule_smiles": "OCC(CO)(CO)n1cnnn1", + "scaffold_smiles": "c1nnn[nH]1" + }, + { + "base_idx": 573158, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 30, + 31 + ], + "molecule_smiles": "CCCc1cc(=O)[nH]c(SCC(=O)c2cc(C)n(-c3cc(C)on3)c2C)n1", + "scaffold_smiles": "O=C(CSc1nccc(=O)[nH]1)c1ccn(-c2ccon2)c1" + }, + { + "base_idx": 167922, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 25 + ], + "molecule_smiles": "Cc1oc(-c2ccoc2)nc1CN(C[C@@H]1CCC(=O)N1)C(C)C", + "scaffold_smiles": "O=C1CCC(CNCc2coc(-c3ccoc3)n2)N1" + }, + { + "base_idx": 539241, + "atom_indices": [ + 7, + 9, + 10, + 11, + 14, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 31 + ], + "molecule_smiles": "CCOC(=O)[C@H]1S[C@]2(C(F)(F)C(F)F)C[C@@]1(O)c1c(c(OC)c3occc3c1OC)O2", + "scaffold_smiles": "c1cc2cc3c(cc2o1)OC1CC3CS1" + }, + { + "base_idx": 624743, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "C[C@@H]1CC[C@@H]2[C@@H](C1)OS(=O)(=O)NC2(C)C", + "scaffold_smiles": "O=S1(=O)NCC2CCCCC2O1" + }, + { + "base_idx": 547754, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11 + ], + "molecule_smiles": "CCNc1ncnc2c1cnn2C", + "scaffold_smiles": "c1ncc2cn[nH]c2n1" + }, + { + "base_idx": 128285, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "O/N=C(/CN(O)/C=C1/C=CC=N1)c1cccs1", + "scaffold_smiles": "N=C(CNC=C1C=CC=N1)c1cccs1" + }, + { + "base_idx": 630088, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 23 + ], + "molecule_smiles": "C[C@]12CC[C@H]3C(=CCc4cc(O)ccc43)[C@H]1CCC2=O", + "scaffold_smiles": "O=C1CCC2C3=CCc4ccccc4C3CCC12" + }, + { + "base_idx": 69, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "c1csc(Cc2nc(-c3ccc(-n4cccc4)cc3)no2)c1", + "scaffold_smiles": "c1csc(Cc2nc(-c3ccc(-n4cccc4)cc3)no2)c1" + }, + { + "base_idx": 209610, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "Cc1ccc2c(c1)-c1nc3ccccc3cc1/C2=N/C1CCCCC1", + "scaffold_smiles": "c1ccc2c(c1)C(=NC1CCCCC1)c1cc3ccccc3nc1-2" + }, + { + "base_idx": 137252, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 34, + 35, + 36, + 37, + 40, + 41, + 42, + 46, + 47, + 48 + ], + "molecule_smiles": "NC(=O)C1=C[C@@H]2OS(=O)(=O)[C@H](c3ccccc3)C(=O)N[C@H]3[C@H]4SCC(=C5C(=O)O[C@]3([O-])N54)CN2C=C1", + "scaffold_smiles": "O=C1OC2C3NC(=O)C(c4ccccc4)S(=O)(=O)OC4C=CC=CN4CC4=C1N2C3SC4" + }, + { + "base_idx": 578416, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 25 + ], + "molecule_smiles": "CCCNC(=O)Nc1ccc2c(c1)N(C)C([O-])=c1ccccc1=[S+]2", + "scaffold_smiles": "C1=c2ccccc2=[S+]c2ccccc2N1" + }, + { + "base_idx": 173566, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 24, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 51, + 52, + 53, + 54, + 55, + 56, + 57 + ], + "molecule_smiles": "Cc1nn(Cc2cccc(Oc3ccccc3)c2)c(C)c1NC(=O)N[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2", + "scaffold_smiles": "O=C(Nc1cnn(Cc2cccc(Oc3ccccc3)c2)c1)NC12CC3CC(CC(C3)C1)C2" + }, + { + "base_idx": 51448, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 11, + 13, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 24 + ], + "molecule_smiles": "O=C(Nc1nccs1)[C@@H]1[C@@H]2C[C@@H]3[C@H]1C(=O)O[C@@H]3C2", + "scaffold_smiles": "O=C(Nc1nccs1)C1C2CC3OC(=O)C1C3C2" + }, + { + "base_idx": 447824, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 33, + 35, + 37 + ], + "molecule_smiles": "CN1C[C@@H]2C[C@@H](C3=Cc4cc(Cl)ccc4OC3)N3CCC[C@@]23C1=O", + "scaffold_smiles": "O=C1NCC2CC(C3=Cc4ccccc4OC3)N3CCCC123" + }, + { + "base_idx": 324332, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 28, + 30, + 34, + 35, + 36, + 37, + 38, + 39, + 49, + 50, + 51 + ], + "molecule_smiles": "COc1cccc(-n2c(=S)sc3c(=O)n(-c4ccccc4OC)c(SCC4OCCO4)nc32)c1", + "scaffold_smiles": "O=c1c2sc(=S)n(-c3ccccc3)c2nc(SCC2OCCO2)n1-c1ccccc1" + }, + { + "base_idx": 581385, + "atom_indices": [ + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CCCN(CCC)Cc1c(C(=O)OCC)nnn1-c1nonc1N", + "scaffold_smiles": "c1cn(-c2cnon2)nn1" + }, + { + "base_idx": 683509, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "CC(=O)N(c1ccc2oc(=O)sc2c1)S(=O)(=O)c1cccs1", + "scaffold_smiles": "O=c1oc2ccc(NS(=O)(=O)c3cccs3)cc2s1" + }, + { + "base_idx": 155197, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 32, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "Cn1c2ccccc2c2cc(C(=O)OCC(=O)N3CC(=O)Nc4ccccc43)sc21", + "scaffold_smiles": "O=C1CN(C(=O)COC(=O)c2cc3c([nH]c4ccccc43)s2)c2ccccc2N1" + }, + { + "base_idx": 624569, + "atom_indices": [ + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 33, + 34 + ], + "molecule_smiles": "C[C@H]1CC[C@]2(CC1)NC(=O)N(Cc1cc(=O)n3ccsc3n1)C2=O", + "scaffold_smiles": "O=C1NC2(CCCCC2)C(=O)N1Cc1cc(=O)n2ccsc2n1" + }, + { + "base_idx": 134773, + "atom_indices": [ + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 27, + 29, + 35, + 36 + ], + "molecule_smiles": "NNC(=O)C1=NO[C@@H]2COC3(CCN(c4ncccn4)CC3)OC[C@H]12", + "scaffold_smiles": "C1=NOC2COC3(CCN(c4ncccn4)CC3)OCC12" + }, + { + "base_idx": 633474, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 33, + 44 + ], + "molecule_smiles": "CC1(C)CC(=O)C2=C(C1)OC(N)=C(C#N)[C@@]21C(=O)N2CCCc3cccc1c32", + "scaffold_smiles": "O=C1CCCC2=C1C1(C=CO2)C(=O)N2CCCc3cccc1c32" + }, + { + "base_idx": 682884, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 21, + 22, + 23, + 26, + 27, + 30, + 31, + 33, + 34 + ], + "molecule_smiles": "CC(=O)N1CN(C2CC(C)(C)NC(C)(C)C2)Cc2c1sc1c2CCCC1", + "scaffold_smiles": "C1CCc2c(sc3c2CN(C2CCNCC2)CN3)C1" + }, + { + "base_idx": 531567, + "atom_indices": [ + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 43, + 45 + ], + "molecule_smiles": "CCOC(=O)N1CCN(C(=O)CCCCCn2c(=S)[nH]c3ccsc3c2=O)CC1", + "scaffold_smiles": "O=C(CCCCCn1c(=S)[nH]c2ccsc2c1=O)N1CCNCC1" + }, + { + "base_idx": 466879, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCn1ccnc1CN(C)C(=O)c1cc(COc2ccc(-n3cncn3)cc2)on1", + "scaffold_smiles": "O=C(NCc1ncc[nH]1)c1cc(COc2ccc(-n3cncn3)cc2)on1" + }, + { + "base_idx": 449828, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 22, + 25, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 44, + 45, + 46, + 48 + ], + "molecule_smiles": "CN1CCN([C@H](c2cc3cc4c(cc3[nH]c2=O)OCCO4)c2nnnn2C2CCCC2)CC1", + "scaffold_smiles": "O=c1[nH]c2cc3c(cc2cc1C(c1nnnn1C1CCCC1)N1CCNCC1)OCCO3" + }, + { + "base_idx": 488938, + "atom_indices": [ + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 23, + 49 + ], + "molecule_smiles": "CC[C@H](C)C(=O)O[C@H]1C[C@H](O)C=C2C=C[C@H](C)[C@H](CC[C@@H](O)C[C@@H](O)CC(=O)O)[C@H]21", + "scaffold_smiles": "C1=CC2=CCCCC2CC1" + }, + { + "base_idx": 542255, + "atom_indices": [ + 7, + 8, + 9, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 38 + ], + "molecule_smiles": "CCOC(=O)C1=C[C@@]2(CC)CCCN3CCc4c(n1c1ccccc41)[C@H]32", + "scaffold_smiles": "C1=Cn2c3c(c4ccccc42)CCN2CCCC1C32" + }, + { + "base_idx": 593892, + "atom_indices": [ + 10, + 12, + 14, + 17, + 19, + 22, + 23 + ], + "molecule_smiles": "CCCCC[C@H](O)/C=C/[C@@H]1[C@@H](C/C=C\\CCCC(=O)O)[C@H]2CO[C@@H]1C2", + "scaffold_smiles": "C1CC2CC1CO2" + }, + { + "base_idx": 117949, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 31, + 32, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "O=C(CCS(=O)(=O)c1cccc2nsnc12)N1CC(=O)N(CCc2ccccc2)C(=O)C1", + "scaffold_smiles": "O=C(CCS(=O)(=O)c1cccc2nsnc12)N1CC(=O)N(CCc2ccccc2)C(=O)C1" + }, + { + "base_idx": 149409, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "Fc1ccc(CSc2ccc3nnc(-c4ccccn4)n3n2)cc1", + "scaffold_smiles": "c1ccc(CSc2ccc3nnc(-c4ccccn4)n3n2)cc1" + }, + { + "base_idx": 180526, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 8, + 10, + 11, + 12, + 13, + 16, + 19 + ], + "molecule_smiles": "Cc1c[s+]c2c(C#N)c([O-])c3c(n12)CCC3", + "scaffold_smiles": "c1cn2c3c(ccc2[s+]1)CCC3" + }, + { + "base_idx": 209208, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 31, + 32, + 33 + ], + "molecule_smiles": "Cc1ccc2cc([C@H]3C(C#N)=C(N)Oc4c3ccc3cccnc43)c(=O)[nH]c2c1", + "scaffold_smiles": "O=c1[nH]c2ccccc2cc1C1C=COc2c1ccc1cccnc21" + }, + { + "base_idx": 583764, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 29, + 30 + ], + "molecule_smiles": "CCCCn1c(N)c(N(CCC(C)C)C(=O)CN2C(=O)[C@H]3CCCC[C@H]3C2=O)c(=O)[nH]c1=O", + "scaffold_smiles": "O=C(CN1C(=O)C2CCCCC2C1=O)Nc1c[nH]c(=O)[nH]c1=O" + }, + { + "base_idx": 484, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "c1cnc2nc(CSc3ncn[nH]3)cn2c1", + "scaffold_smiles": "c1cnc2nc(CSc3ncn[nH]3)cn2c1" + }, + { + "base_idx": 541691, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 24, + 25, + 26, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "CCOC(=O)C1=c2/c(=C/c3ccncc3)c(N)c(C(=O)OC)n2C(=O)[C@@H](c2ccncc2)S1", + "scaffold_smiles": "O=C1C(c2ccncc2)SC=c2c(=Cc3ccncc3)ccn21" + }, + { + "base_idx": 475577, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 12, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "CCc1nc2c(n1O)CCc1nonc1-2", + "scaffold_smiles": "c1nc2c([nH]1)CCc1nonc1-2" + }, + { + "base_idx": 696683, + "atom_indices": [ + 3, + 4, + 5, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 25 + ], + "molecule_smiles": "C=CCN1/C(=N/C(=O)CC)S[C@@H]2CS(=O)(=O)C[C@@H]21", + "scaffold_smiles": "N=C1NC2CS(=O)(=O)CC2S1" + }, + { + "base_idx": 631172, + "atom_indices": [ + 4, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 26, + 31, + 33, + 37, + 38, + 43, + 50 + ], + "molecule_smiles": "CC1(C)O[C@@H]2C[C@H]3[C@H]4CCC5=CC(=O)CC[C@@]5(C)[C@]4(F)[C@@H](O)C[C@@]3(C)[C@]2(C(=O)CCl)O1", + "scaffold_smiles": "O=C1C=C2CCC3C(CCC4C3CC3OCOC34)C2CC1" + }, + { + "base_idx": 412244, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 22, + 23, + 24, + 25, + 26, + 28, + 29, + 30, + 31, + 32, + 33, + 39, + 40, + 41, + 42, + 43, + 44, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54 + ], + "molecule_smiles": "COc1cc(-c2nc(-c3ccc4c(c3)-c3ccccc3S4(=O)=O)c(-c3ccccc3)[nH]2)cc(OC)c1OC", + "scaffold_smiles": "O=S1(=O)c2ccccc2-c2cc(-c3nc(-c4ccccc4)[nH]c3-c3ccccc3)ccc21" + }, + { + "base_idx": 176186, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 27, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 42, + 44, + 45, + 47, + 55 + ], + "molecule_smiles": "CC1=C2/C=N/NC(=O)CSc3nnc(s3)SCc3cccc4c3[C@@H]3[C@H]5N1[C@@H]([C@H]4[C@@]23C)N5c1ccccc1", + "scaffold_smiles": "O=C1CSc2nnc(s2)SCc2cccc3c2C2C4C(=CN5C(C34)N(c3ccccc3)C25)C=NN1" + }, + { + "base_idx": 384683, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 38, + 39, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "COc1ccc(/C=C/c2c(-c3ccc(OC)cc3)oc3ccc(-c4ccc(OC)cc4)cc23)cc1", + "scaffold_smiles": "C(=Cc1c(-c2ccccc2)oc2ccc(-c3ccccc3)cc12)c1ccccc1" + }, + { + "base_idx": 49316, + "atom_indices": [ + 4, + 6, + 12, + 13, + 14, + 15, + 17, + 20 + ], + "molecule_smiles": "O=C(O)[C@H]1[C@H](C(=O)O)[C@H]2C=C[C@@H]1CC2", + "scaffold_smiles": "C1=CC2CCC1CC2" + }, + { + "base_idx": 697073, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 33, + 34, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "C=C1S/C(=N\\c2ccccc2Cl)N(C(=O)c2sc3ccccc3c2Cl)C12CCCCC2", + "scaffold_smiles": "C=C1SC(=Nc2ccccc2)N(C(=O)c2cc3ccccc3s2)C12CCCCC2" + }, + { + "base_idx": 589801, + "atom_indices": [ + 5, + 6, + 7, + 8, + 10, + 12 + ], + "molecule_smiles": "CCCCN1CCNC1=S", + "scaffold_smiles": "S=C1NCCN1" + }, + { + "base_idx": 250219, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "Cc1ccc(-n2[nH]c(C)c(/C=N/c3ccc4[nH]c(=O)[nH]c4c3)c2=O)cc1", + "scaffold_smiles": "O=c1[nH]c2ccc(N=Cc3c[nH]n(-c4ccccc4)c3=O)cc2[nH]1" + }, + { + "base_idx": 539876, + "atom_indices": [ + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 28 + ], + "molecule_smiles": "CCOC(=O)C1CCN(/C=N/c2cc(C)on2)CC1", + "scaffold_smiles": "C(=Nc1ccon1)N1CCCCC1" + }, + { + "base_idx": 542942, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 24, + 25, + 26, + 27, + 34, + 39, + 40, + 41, + 42, + 43, + 44, + 50 + ], + "molecule_smiles": "CCOC(=O)C1=NN(c2ccc(C)cc2)[C@]2(S1)SC(C(C)=O)=C(C)N2c1ccccc1", + "scaffold_smiles": "C1=CN(c2ccccc2)C2(S1)SC=NN2c1ccccc1" + }, + { + "base_idx": 644661, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 31, + 32, + 33, + 34, + 35, + 36, + 44 + ], + "molecule_smiles": "CC(C)c1ccc2c(c1)/C(=N/N=C(\\O)c1ccc(CSc3nncn3C)cc1)C(=O)N2", + "scaffold_smiles": "O=C1Nc2ccccc2C1=NN=Cc1ccc(CSc2nnc[nH]2)cc1" + }, + { + "base_idx": 590072, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "CCCCN1C(=O)C[C@H](/[S+]=C2N=CC=CC\\2=C(\\[O-])O)C1=O", + "scaffold_smiles": "C=C1C=CC=NC1=[S+]C1CC(=O)NC1=O" + }, + { + "base_idx": 521537, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 27, + 34, + 35, + 36 + ], + "molecule_smiles": "CCOC(=O)c1cc2sccc2n1CC(=O)c1ccc2c(c1)OCCO2", + "scaffold_smiles": "O=C(Cn1ccc2sccc21)c1ccc2c(c1)OCCO2" + }, + { + "base_idx": 604122, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 16, + 18, + 19, + 20, + 29, + 30 + ], + "molecule_smiles": "CC[C@H]1CC[C@H](N[C@@H]2CCCCNC2=O)CC1", + "scaffold_smiles": "O=C1NCCCCC1NC1CCCCC1" + }, + { + "base_idx": 425581, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 33, + 34, + 35 + ], + "molecule_smiles": "COC[C@H](C)n1c(C)cc(C(=O)COC(=O)c2c(NC(C)=O)sc(C)c2C)c1C", + "scaffold_smiles": "O=C(COC(=O)c1ccsc1)c1cc[nH]c1" + }, + { + "base_idx": 699204, + "atom_indices": [ + 6, + 7, + 9, + 11, + 12, + 13, + 15, + 18, + 21, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "C=C(C)[C@@H]1[C@H]2OC(=O)[C@@H]1[C@]1(O)C[C@H]3O[C@]34C(=O)O[C@H]2[C@]14C", + "scaffold_smiles": "O=C1OC2CC1C1CC3OC34C(=O)OC2C14" + }, + { + "base_idx": 1351, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "c1ccc(SCCN2CCc3ccccc3C2)cc1", + "scaffold_smiles": "c1ccc(SCCN2CCc3ccccc3C2)cc1" + }, + { + "base_idx": 333408, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 25, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 50 + ], + "molecule_smiles": "COc1ccc(S(=O)(=O)Cc2ccc(C(=O)NCCCN(C)c3ccccc3)o2)cc1", + "scaffold_smiles": "O=C(NCCCNc1ccccc1)c1ccc(CS(=O)(=O)c2ccccc2)o1" + }, + { + "base_idx": 59665, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "O=C(Nc1ccc(Cl)cn1)c1cnn2cccnc12", + "scaffold_smiles": "O=C(Nc1ccccn1)c1cnn2cccnc12" + }, + { + "base_idx": 24129, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 25, + 27, + 28, + 29, + 31, + 34, + 36, + 37 + ], + "molecule_smiles": "O=S(=O)(NC1CC1)c1ccc(S(=O)(=O)N2CCN3CCC[C@H]3C2)cc1", + "scaffold_smiles": "O=S(=O)(NC1CC1)c1ccc(S(=O)(=O)N2CCN3CCCC3C2)cc1" + }, + { + "base_idx": 151572, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "FC(F)(F)c1ccc(CNc2ccc(Cc3c[nH]c4ncc(Cl)cc34)cn2)cn1", + "scaffold_smiles": "c1cncc(CNc2ccc(Cc3c[nH]c4ncccc34)cn2)c1" + }, + { + "base_idx": 293570, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 21 + ], + "molecule_smiles": "CSC1=N[P@](=S)(SC)N(C)c2nc3n(c21)CCC3", + "scaffold_smiles": "S=[PH]1N=Cc2c(nc3n2CCC3)N1" + }, + { + "base_idx": 187450, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 35 + ], + "molecule_smiles": "Cc1ccccc1NC1=[S+]/C(=[S+]/Cc2cc(N([O-])[O-])cc3c2OCOC3)N=N1", + "scaffold_smiles": "c1ccc(NC2=[S+]C(=[S+]Cc3cccc4c3OCOC4)N=N2)cc1" + }, + { + "base_idx": 30278, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 21, + 22, + 23, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38 + ], + "molecule_smiles": "O=C1[C@@H]2[C@H]3c4ccccc4[C@H](c4ccccc43)[C@@H]2C(=O)N1c1nc(-c2ccccc2)cs1", + "scaffold_smiles": "O=C1C2C3c4ccccc4C(c4ccccc43)C2C(=O)N1c1nc(-c2ccccc2)cs1" + }, + { + "base_idx": 582922, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 35, + 36, + 37, + 38, + 39, + 41 + ], + "molecule_smiles": "CCCCN1C(=O)/C(=C\\NN2CCCC[C@@H]2c2cccnc2)C(=O)NC1=S", + "scaffold_smiles": "O=C1NC(=S)NC(=O)C1=CNN1CCCCC1c1cccnc1" + }, + { + "base_idx": 264252, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "Cc1cc(COc2ccccc2OCc2cc(C)no2)on1", + "scaffold_smiles": "c1ccc(OCc2ccno2)c(OCc2ccno2)c1" + }, + { + "base_idx": 599114, + "atom_indices": [ + 6, + 7, + 9, + 11, + 13, + 16, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 37, + 39 + ], + "molecule_smiles": "CCCCC(=O)N(Cc1nc2[s+]c(C)c(C)c-2c([O-])[nH]1)C1CCCC1", + "scaffold_smiles": "c1cc2c[nH]c(CNC3CCCC3)nc-2[s+]1" + }, + { + "base_idx": 326489, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 29, + 30 + ], + "molecule_smiles": "COc1ccc2cc(-c3[nH]ncc3CN3CCCCO3)ccc2c1", + "scaffold_smiles": "c1ccc2cc(-c3[nH]ncc3CN3CCCCO3)ccc2c1" + }, + { + "base_idx": 523783, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CCOC(=O)c1c(C)nc2sccn12", + "scaffold_smiles": "c1cn2ccsc2n1" + }, + { + "base_idx": 699556, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 23, + 24, + 25 + ], + "molecule_smiles": "C1CCc2c(nc3nnnn3c2NCC2CC2)C1", + "scaffold_smiles": "C1CCc2c(nc3nnnn3c2NCC2CC2)C1" + }, + { + "base_idx": 443293, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 43, + 50, + 57 + ], + "molecule_smiles": "COC(=O)C1=[S+]C2([S+]=C1C(=O)OC)C1=C(SC(=C(/[O-])OC)/C2=C(/[O-])OC)C(C)(C)Nc2c(C)cc(C)cc21", + "scaffold_smiles": "C=C1SC2=C(c3ccccc3NC2)C2([S+]=CC=[S+]2)C1=C" + }, + { + "base_idx": 467984, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CCn1nc(C)c(CNC(=O)C[C@@H]2C(=O)NCCN2CC(C)(C)C)c1C", + "scaffold_smiles": "O=C(CC1NCCNC1=O)NCc1cn[nH]c1" + }, + { + "base_idx": 633319, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 16, + 19, + 20, + 21, + 23, + 24, + 25, + 26, + 27, + 29, + 31 + ], + "molecule_smiles": "CC1(C)CC(=O)C(/C=N/CCN2CCNC2=O)=C(O)C1", + "scaffold_smiles": "O=C1CCCC=C1C=NCCN1CCNC1=O" + }, + { + "base_idx": 244717, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "Cc1ccc(C(=O)O/N=C2\\CCS(=O)(=O)c3sccc32)cc1", + "scaffold_smiles": "O=C(ON=C1CCS(=O)(=O)c2sccc21)c1ccccc1" + }, + { + "base_idx": 685263, + "atom_indices": [ + 3, + 4, + 6, + 9, + 10, + 12, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 34, + 36, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "CC(=O)N1CCC2(CC1)C/C(=N\\NC(=S)Nc1ccccc1F)c1cc(O)ccc1O2", + "scaffold_smiles": "S=C(NN=C1CC2(CCNCC2)Oc2ccccc21)Nc1ccccc1" + }, + { + "base_idx": 627891, + "atom_indices": [ + 1, + 2, + 3, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 30, + 31 + ], + "molecule_smiles": "CC1=CC[C@@H](/C=N\\Nc2nnc3c(n2)[nH]c2ccccc23)CC1", + "scaffold_smiles": "C1=CCC(C=NNc2nnc3c(n2)[nH]c2ccccc23)CC1" + }, + { + "base_idx": 517639, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 13, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "CCOC(=O)c1cnn(CCOC(=O)C2CC2)c1NC(=O)C1CC1", + "scaffold_smiles": "O=C(Nc1ccnn1CCOC(=O)C1CC1)C1CC1" + }, + { + "base_idx": 36286, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 16, + 17, + 18, + 19, + 20, + 21, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "O=C(c1cccc(C(F)(F)F)c1)[C@H]1CCCN(Cc2cccn2-c2ncccn2)C1", + "scaffold_smiles": "O=C(c1ccccc1)C1CCCN(Cc2cccn2-c2ncccn2)C1" + }, + { + "base_idx": 576464, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 24, + 25, + 26, + 27, + 29, + 31 + ], + "molecule_smiles": "CCCOC(=O)[C@@H]1[C@H]2C(=O)N(Cc3ccco3)C[C@]23C=C[C@H]1O3", + "scaffold_smiles": "O=C1C2CC3C=CC2(CN1Cc1ccco1)O3" + }, + { + "base_idx": 414129, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "COCc1cc(=O)n2c([nH]c3ccccc32)c1C#N", + "scaffold_smiles": "O=c1cccc2[nH]c3ccccc3n12" + }, + { + "base_idx": 490877, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 29 + ], + "molecule_smiles": "CCOc1nc(NC(C)C)nc(N2C[C@@H]3C[C@H](C2)c2cccc(=O)n2C3)n1", + "scaffold_smiles": "O=c1cccc2n1CC1CC2CN(c2ncncn2)C1" + }, + { + "base_idx": 145568, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "N#Cc1c(-c2ccncc2)c2c([nH]c1=S)CCCC2", + "scaffold_smiles": "S=c1cc(-c2ccncc2)c2c([nH]1)CCCC2" + }, + { + "base_idx": 357043, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 38, + 39, + 40, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "COc1ccc(Cc2nc3c4c(ncn3n2)Oc2ccc3ccccc3c2[C@@H]4c2ccc(OC)c(OC)c2)cc1", + "scaffold_smiles": "c1ccc(Cc2nc3c4c(ncn3n2)Oc2ccc3ccccc3c2C4c2ccccc2)cc1" + }, + { + "base_idx": 436745, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "COC(=O)Nc1nc2ccc(-c3n[nH]c(=O)c4ccccc34)cc2[nH]1", + "scaffold_smiles": "O=c1[nH]nc(-c2ccc3nc[nH]c3c2)c2ccccc12" + }, + { + "base_idx": 639514, + "atom_indices": [ + 4, + 5, + 7, + 8, + 11, + 12, + 14, + 15, + 16, + 25, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 47, + 48, + 49, + 50, + 51 + ], + "molecule_smiles": "C[C@H](NCC[C@@]1(Cc2ccccc2Cl)CCOC(C)(C)C1)c1cccs1", + "scaffold_smiles": "c1ccc(CC2(CCNCc3cccs3)CCOCC2)cc1" + }, + { + "base_idx": 442244, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 29, + 30, + 31 + ], + "molecule_smiles": "COC(=O)C1=C(C)NC(C)=C(C(=O)OC)C1C(=O)O[C@@H]1CCCCC1=O", + "scaffold_smiles": "O=C(OC1CCCCC1=O)C1C=CNC=C1" + }, + { + "base_idx": 525772, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 26, + 27, + 28, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "CCOC(=O)c1c(/N=C\\C2C(=O)CC(C)(C)CC2=O)sc2c1CCCC2", + "scaffold_smiles": "O=C1CCCC(=O)C1C=Nc1cc2c(s1)CCCC2" + }, + { + "base_idx": 626854, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 29 + ], + "molecule_smiles": "CC1=C[C@H]2Cc3cc(C)cc(C)c3S(=O)(=O)[C@@H]2C=C1", + "scaffold_smiles": "O=S1(=O)c2ccccc2CC2C=CC=CC21" + }, + { + "base_idx": 517819, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 24, + 25, + 26, + 27, + 28, + 29, + 31, + 32, + 33 + ], + "molecule_smiles": "CCOC(=O)c1nnn(-c2nonc2-n2cccc2)c1CN1CCOCC1", + "scaffold_smiles": "c1ccn(-c2nonc2-n2nncc2CN2CCOCC2)c1" + }, + { + "base_idx": 75357, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 19, + 20, + 21, + 22, + 23, + 24, + 27, + 28, + 29, + 30, + 31, + 32, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "O=C(NC1CCOCC1)c1ccc(-c2cc(-c3c[nH]nc3-c3ccccn3)ccn2)cc1", + "scaffold_smiles": "O=C(NC1CCOCC1)c1ccc(-c2cc(-c3c[nH]nc3-c3ccccn3)ccn2)cc1" + }, + { + "base_idx": 73325, + "atom_indices": [ + 0, + 1, + 2, + 4, + 7, + 10, + 11, + 12, + 13, + 14, + 16, + 19, + 25, + 26, + 27, + 28, + 29, + 30, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 49, + 50, + 51 + ], + "molecule_smiles": "O=C(NCCCN1CCCC1=O)c1ccc2c(c1)NC(=O)[C@@H](N1CCOCC1)S2", + "scaffold_smiles": "O=C(NCCCN1CCCC1=O)c1ccc2c(c1)NC(=O)C(N1CCOCC1)S2" + }, + { + "base_idx": 303379, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "COc1ccccc1N(CCN1C(=O)c2cccc3cccc(c23)C1=O)C(=O)c1ccncc1", + "scaffold_smiles": "O=C1c2cccc3cccc(c23)C(=O)N1CCN(C(=O)c1ccncc1)c1ccccc1" + }, + { + "base_idx": 665316, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 15 + ], + "molecule_smiles": "CC(C)(C)NC(=O)C1=[S+]C=C/C1=C(\\[O-])O", + "scaffold_smiles": "C=C1C=C[S+]=C1" + }, + { + "base_idx": 695720, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 30, + 33 + ], + "molecule_smiles": "C=CC/N=C1/NN=C(c2cc(C)n(CC=C)c2C)CS1", + "scaffold_smiles": "N=C1NN=C(c2cc[nH]c2)CS1" + }, + { + "base_idx": 426787, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 22, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CO[C@]12[C@H](COC(N)=O)C3=C(C(=O)C(C)=C(N)C3=O)N1C[C@@H]1[C@H]2N1C", + "scaffold_smiles": "O=C1C=CC(=O)C2=C1CC1C3NC3CN21" + }, + { + "base_idx": 397293, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 17, + 18, + 20, + 21, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 47, + 48 + ], + "molecule_smiles": "COc1cc2c(cc1OC)[C@@H]1C(=O)c3ccc4oc(C(C)C)cc4c3O[C@@H]1CO2", + "scaffold_smiles": "O=C1c2ccc3occc3c2OC2COc3ccccc3C12" + }, + { + "base_idx": 191447, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 31, + 32, + 33, + 34, + 36, + 37, + 38, + 41 + ], + "molecule_smiles": "Cc1ccccc1[C@@H]1C(C#N)=C2N=C(N)c3c(sc4c3CCCCC4)N2C2=C1C(=O)CCC2", + "scaffold_smiles": "O=C1CCCC2=C1C(c1ccccc1)C=C1N=Cc3c(sc4c3CCCCC4)N12" + }, + { + "base_idx": 447282, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 20, + 21, + 25, + 28, + 35, + 36, + 37 + ], + "molecule_smiles": "CN1CCc2cc3c(cc2C(=O)Cc2ccc4c(c2C1)OCO4)OCO3", + "scaffold_smiles": "O=C1Cc2ccc3c(c2CNCCc2cc4c(cc21)OCO4)OCO3" + }, + { + "base_idx": 327819, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 36 + ], + "molecule_smiles": "COc1ccc2c(c1)C[C@]1(CN2C)C(=O)NC(=O)N(c2ccc(F)cc2)C1=O", + "scaffold_smiles": "O=C1NC(=O)C2(CNc3ccccc3C2)C(=O)N1c1ccccc1" + }, + { + "base_idx": 418262, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "COCCNC(=O)c1ccc2c3n(nc2c1)-c1ccc(C)cc1CO3", + "scaffold_smiles": "c1ccc2c(c1)COc1c3ccccc3nn1-2" + }, + { + "base_idx": 631102, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "C[C@@]1(c2ccc(Cl)c(Cl)c2)NC(=S)N2[C@@H]1NC(=S)N2c1ccccc1", + "scaffold_smiles": "S=C1NC2C(c3ccccc3)NC(=S)N2N1c1ccccc1" + }, + { + "base_idx": 250748, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 36, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "Cc1ccc(-n2c(C)cc(C(=O)C3=C(Nc4ccc(F)c(Cl)c4)S[C@@H]4C=CC=CN34)c2C)cc1", + "scaffold_smiles": "O=C(C1=C(Nc2ccccc2)SC2C=CC=CN12)c1ccn(-c2ccccc2)c1" + }, + { + "base_idx": 489330, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 23, + 24, + 25, + 27, + 28, + 30 + ], + "molecule_smiles": "CCOc1ccccc1O[C@@H](c1ccccc1)[C@@H]1CNCCO1", + "scaffold_smiles": "c1ccc(OC(c2ccccc2)C2CNCCO2)cc1" + }, + { + "base_idx": 485592, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 23 + ], + "molecule_smiles": "CCC1=C([O-])N2N/C(=C3/C=CC=[S+]3)C=C2N=C1C", + "scaffold_smiles": "C1=CC(=C2C=C3N=CC=CN3N2)[S+]=C1" + }, + { + "base_idx": 81332, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "O=C1NC(=O)C(=NNC(=O)[C@H]2COc3cc4ccccc4cc3O2)C(=O)N1", + "scaffold_smiles": "O=C1NC(=O)C(=NNC(=O)C2COc3cc4ccccc4cc3O2)C(=O)N1" + }, + { + "base_idx": 263832, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 23, + 25 + ], + "molecule_smiles": "Cc1cc(C)nc(SCC(=O)NN=C2CCCC2)n1", + "scaffold_smiles": "O=C(CSc1ncccn1)NN=C1CCCC1" + }, + { + "base_idx": 287526, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 24 + ], + "molecule_smiles": "CSc1nc2n(n1)[C@@H](C)C1=C(CCCC1=O)N2", + "scaffold_smiles": "O=C1CCCC2=C1Cn1ncnc1N2" + }, + { + "base_idx": 627025, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CC1=NN(C(N)=S)C(=O)[C@@H]1/N=N/c1c(Br)cc(Br)cc1Br", + "scaffold_smiles": "O=C1NN=CC1N=Nc1ccccc1" + }, + { + "base_idx": 518797, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 22, + 26, + 27 + ], + "molecule_smiles": "CCOC(=O)c1cnc2c(C#N)c3c(cn2c1=O)CCO3", + "scaffold_smiles": "O=c1ccnc2cc3c(cn12)CCO3" + }, + { + "base_idx": 9741, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 35, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "OCC[C@@H]1CN(CCCn2cccn2)CCN1C1CCCCC1", + "scaffold_smiles": "c1cnn(CCCN2CCN(C3CCCCC3)CC2)c1" + }, + { + "base_idx": 141763, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 24, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "[H]/N=c1/c(/C=C2\\C=C/C(=[S+]\\Cc3ccco3)O2)c([O-])nc2scnn12", + "scaffold_smiles": "N=c1c(C=C2C=CC(=[S+]Cc3ccco3)O2)cnc2scnn12" + }, + { + "base_idx": 474254, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 30, + 33 + ], + "molecule_smiles": "CCc1nnc2n1N=C(c1ccc(-c3ccc(Cl)cc3Cl)o1)CS2", + "scaffold_smiles": "c1ccc(-c2ccc(C3=Nn4cnnc4SC3)o2)cc1" + }, + { + "base_idx": 548212, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 21, + 22, + 23, + 25, + 27, + 28, + 29, + 30, + 31, + 32, + 35, + 36, + 38, + 39, + 40, + 41, + 42, + 43, + 45, + 46, + 47, + 48, + 49, + 50, + 59 + ], + "molecule_smiles": "CCO/N=C(\\C(=O)N[C@@H]1C(=O)N2C(=C(\\[O-])O)/C(=[S+]\\C3=NC(=C4C=CN(C)C=C4)C=[S+]3)CS[C@H]12)c1nsc(NP(=O)(O)O)n1", + "scaffold_smiles": "C=C1C(=[S+]C2=NC(=C3C=CNC=C3)C=[S+]2)CSC2C(NC(=O)C(=N)c3ncsn3)C(=O)N12" + }, + { + "base_idx": 195211, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "Cc1cccc(OC(=O)n2c(=S)oc3cc(Cl)ccc32)c1", + "scaffold_smiles": "O=C(Oc1ccccc1)n1c(=S)oc2ccccc21" + }, + { + "base_idx": 674545, + "atom_indices": [ + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 22, + 23, + 24, + 25, + 28, + 29, + 30, + 33 + ], + "molecule_smiles": "CC(=O)O[C@H]1[C@@H]2OC(=O)C(C)=C2[C@@H]2OC(=O)[C@]3(CC[C@@H]4O[C@@]41C)O[C@H]23", + "scaffold_smiles": "O=C1C=C2C(CC3OC3CCC34OC3C2OC4=O)O1" + }, + { + "base_idx": 9208, + "atom_indices": [ + 5, + 6, + 7, + 10, + 11 + ], + "molecule_smiles": "OCCNC1=NCCN1", + "scaffold_smiles": "C1=NCCN1" + }, + { + "base_idx": 285097, + "atom_indices": [ + 2, + 3, + 4, + 14, + 17 + ], + "molecule_smiles": "CN(C)C1=[S+][C@](C)(/C=N/O)CS1", + "scaffold_smiles": "C1=[S+]CCS1" + }, + { + "base_idx": 277858, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 25, + 26, + 27, + 29, + 32, + 35 + ], + "molecule_smiles": "Cc1c(O)c(CN2CCCC2)cc2c3c(c(=O)oc12)CCCC3", + "scaffold_smiles": "O=c1oc2ccc(CN3CCCC3)cc2c2c1CCCC2" + }, + { + "base_idx": 689339, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 16 + ], + "molecule_smiles": "C=CCn1c(C)c(C(C)=O)c(N2CCCC2)nc1=S", + "scaffold_smiles": "S=c1nc(N2CCCC2)cc[nH]1" + }, + { + "base_idx": 664031, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13 + ], + "molecule_smiles": "CC(C)(C)c1cc2nc(N)nc(N)n2n1", + "scaffold_smiles": "c1ncn2nccc2n1" + }, + { + "base_idx": 698808, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 15, + 20, + 21, + 22 + ], + "molecule_smiles": "C=C1C(=O)O[C@@H]2C[C@]3(C)CCC[C@H](C)C3=C[C@H]12", + "scaffold_smiles": "C=C1C(=O)OC2CC3CCCCC3=CC12" + }, + { + "base_idx": 293219, + "atom_indices": [ + 4, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CS1([O-])(c2ccccc2)=NS1(=O)([O-])c1ccc(Cl)cc1", + "scaffold_smiles": "O=[SH]1(c2ccccc2)N=S1c1ccccc1" + }, + { + "base_idx": 131048, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 22, + 26 + ], + "molecule_smiles": "Nc1nc(=O)c2sc(=O)n([C@@H]3O[C@H](CO)[C@@H](O)[C@H]3O)c2[nH]1", + "scaffold_smiles": "O=c1nc[nH]c2c1sc(=O)n2C1CCCO1" + }, + { + "base_idx": 76863, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 25, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 41, + 43, + 46, + 50, + 52 + ], + "molecule_smiles": "O=C(N[C@@H]1CCCc2ccccc21)C1CCN(c2c3c(nc4ncnn24)CCC3)CC1", + "scaffold_smiles": "O=C(NC1CCCc2ccccc21)C1CCN(c2c3c(nc4ncnn24)CCC3)CC1" + }, + { + "base_idx": 249610, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 22, + 23, + 24, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 48 + ], + "molecule_smiles": "Cc1ccc(/C=C(/NC(=O)c2ccccc2)C(=O)NCCc2c[nH]c3ccccc23)o1", + "scaffold_smiles": "O=C(NCCc1c[nH]c2ccccc12)C(=Cc1ccco1)NC(=O)c1ccccc1" + }, + { + "base_idx": 525161, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 32, + 33, + 34, + 35, + 37, + 38, + 39, + 41 + ], + "molecule_smiles": "CCOC(=O)C1=C2CC/C(=C\\[O-])C(Cl)=C2[S+]=C1NC(=O)CN1CCCCC1", + "scaffold_smiles": "C=C1C=C2[S+]=C(NC(=O)CN3CCCCC3)C=C2CC1" + }, + { + "base_idx": 309754, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 25, + 28, + 31, + 34 + ], + "molecule_smiles": "COc1ccccc1/C=C/C(=O)c1c2c(c(C)[nH]c1=O)CCCC2", + "scaffold_smiles": "O=C(C=Cc1ccccc1)c1c2c(c[nH]c1=O)CCCC2" + }, + { + "base_idx": 174464, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 23 + ], + "molecule_smiles": "Cc1ncsc1CC/[S+]=c1\\nc2ccccc2c([O-])n1C", + "scaffold_smiles": "c1ccc2nc(=[S+]CCc3cncs3)[nH]cc2c1" + }, + { + "base_idx": 183583, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 43, + 44, + 45, + 46, + 47, + 48 + ], + "molecule_smiles": "Cc1cn(-c2cc(NC(=O)c3ccc(C)c(Nc4nccc(-c5cnccn5)n4)c3)cc(C(F)(F)F)c2)cn1", + "scaffold_smiles": "O=C(Nc1cccc(-n2ccnc2)c1)c1cccc(Nc2nccc(-c3cnccn3)n2)c1" + }, + { + "base_idx": 288458, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "CSc1n[nH]c(/N=C/c2oc(O)c3ccccc23)n1", + "scaffold_smiles": "C(=Nc1ncn[nH]1)c1occ2ccccc12" + }, + { + "base_idx": 454022, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 21, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 34, + 40 + ], + "molecule_smiles": "CN(Cc1nc(CC2CC2)no1)C[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2", + "scaffold_smiles": "C(NCC12CC3CC(CC(C3)C1)C2)c1nc(CC2CC2)no1" + }, + { + "base_idx": 396962, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "COc1cc2c(cc1NCc1ccccn1)oc1ccccc12", + "scaffold_smiles": "c1ccc(CNc2ccc3c(c2)oc2ccccc23)nc1" + }, + { + "base_idx": 524690, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CCOC(=O)c1c(C)sc2c1N(C(=O)CCl)c1cc(Cl)ccc1S2", + "scaffold_smiles": "c1ccc2c(c1)Nc1ccsc1S2" + }, + { + "base_idx": 554657, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 27, + 28, + 29, + 30, + 32, + 33, + 34, + 35, + 41, + 50, + 51, + 55, + 56 + ], + "molecule_smiles": "CCN1CCN(C(=O)N[C@@H](C(=O)N[C@@H]2C(=O)N3[C@@H]2SC(C)(C)[C@@H]3C(=O)O)c2ccccc2)C(=O)C1=O", + "scaffold_smiles": "O=C1NCCN(C(=O)NC(C(=O)NC2C(=O)N3CCSC23)c2ccccc2)C1=O" + }, + { + "base_idx": 209860, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 40, + 41, + 42, + 47 + ], + "molecule_smiles": "Cc1ccc2c(c1)[C@@H]1CN(C)CC[C@@H]1N2C(=O)c1ccccc1NS(=O)(=O)c1ccc(F)cc1", + "scaffold_smiles": "O=C(c1ccccc1NS(=O)(=O)c1ccccc1)N1c2ccccc2C2CNCCC21" + }, + { + "base_idx": 628980, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 11, + 12, + 13, + 16, + 21, + 22, + 23, + 24, + 25, + 26, + 33, + 34, + 35, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 50, + 51, + 52 + ], + "molecule_smiles": "CC1=C(C(=O)OC[C@H]2CCCO2)[C@H](c2ccc(C)o2)C2=C(C[C@H](c3ccc(F)cc3)CC2=O)N1", + "scaffold_smiles": "O=C1CC(c2ccccc2)CC2=C1C(c1ccco1)C(C(=O)OCC1CCCO1)=CN2" + }, + { + "base_idx": 596713, + "atom_indices": [ + 4, + 6, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21 + ], + "molecule_smiles": "[H]/N=C1\\O[C@]23CCCC[C@H]2C(C#N)(C#N)[C@@]1(C#N)[C@H](CCCC)O3", + "scaffold_smiles": "N=C1OC23CCCCC2CC1CO3" + }, + { + "base_idx": 632827, + "atom_indices": [ + 4, + 9, + 11, + 12, + 14, + 15, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 33, + 36, + 37, + 38 + ], + "molecule_smiles": "CC1(C)C[C@H]([C@H]2N3C[C@@]4(C)CN2C[C@@](C)(C3)C4=O)CCO1", + "scaffold_smiles": "O=C1C2CN3CC1CN(C2)C3C1CCOCC1" + }, + { + "base_idx": 399915, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17, + 19, + 22, + 23, + 24, + 25, + 27, + 30, + 31, + 32 + ], + "molecule_smiles": "COc1cc(O)cc2c1C(=O)O[C@H](C)CCCC(=O)CCC/C=C\\2", + "scaffold_smiles": "O=C1CCCC=Cc2ccccc2C(=O)OCCCC1" + }, + { + "base_idx": 529462, + "atom_indices": [ + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 16, + 18, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 31, + 44, + 45, + 46, + 47, + 48, + 51 + ], + "molecule_smiles": "CCOC(=O)N1CCN(c2c(NCCCN3CCCCCC3)c(=O)c2=O)CC1", + "scaffold_smiles": "O=c1c(NCCCN2CCCCCC2)c(N2CCNCC2)c1=O" + }, + { + "base_idx": 205194, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 40, + 41, + 42 + ], + "molecule_smiles": "Cc1cccc(-n2ncc3c(NCCc4ccc(S(N)(=O)=O)cc4)ncnc32)c1", + "scaffold_smiles": "c1ccc(CCNc2ncnc3c2cnn3-c2ccccc2)cc1" + }, + { + "base_idx": 580113, + "atom_indices": [ + 3, + 4, + 5, + 8, + 9, + 11, + 17, + 18, + 26, + 27 + ], + "molecule_smiles": "CCCN1CCC2(CC1)NC(CO)(CO)CO2", + "scaffold_smiles": "C1CC2(CCN1)NCCO2" + }, + { + "base_idx": 410310, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "COc1cc(/C=C2\\SC(=O)NC2=O)ccc1Oc1cnccn1", + "scaffold_smiles": "O=C1NC(=O)C(=Cc2ccc(Oc3cnccn3)cc2)S1" + }, + { + "base_idx": 26450, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "O=C1NN(c2ccccc2)C(=O)/C1=C/c1ccc(Sc2cccc3ccccc23)o1", + "scaffold_smiles": "O=C1NN(c2ccccc2)C(=O)C1=Cc1ccc(Sc2cccc3ccccc23)o1" + }, + { + "base_idx": 5481, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 6, + 7, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "S=C(Nc1ccc2nsnc2c1)N1CCCC1", + "scaffold_smiles": "S=C(Nc1ccc2nsnc2c1)N1CCCC1" + }, + { + "base_idx": 252666, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 34, + 35, + 36 + ], + "molecule_smiles": "Cc1ccc(-c2nnc(CSc3nc4cc5c(cc4cc3C)OCO5)o2)cc1", + "scaffold_smiles": "c1ccc(-c2nnc(CSc3ccc4cc5c(cc4n3)OCO5)o2)cc1" + }, + { + "base_idx": 151444, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "Cn1nnnc1/N=N/C(=N/Nc1ccccc1)c1ccccc1", + "scaffold_smiles": "c1ccc(NN=C(N=Nc2nnn[nH]2)c2ccccc2)cc1" + }, + { + "base_idx": 13248, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 7, + 9, + 10, + 12, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 39 + ], + "molecule_smiles": "O=c1cc(-c2cccc3c2Sc2ccccc2S3)oc(N2CCOCC2)c1", + "scaffold_smiles": "O=c1cc(-c2cccc3c2Sc2ccccc2S3)oc(N2CCOCC2)c1" + }, + { + "base_idx": 654760, + "atom_indices": [ + 23, + 24, + 25, + 26, + 27, + 30, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 45, + 46 + ], + "molecule_smiles": "CC(C)CNC(=O)NC(=O)COC(=O)[C@H]1CS[C@]2(c3ccc(F)cc3)CCC(=O)N12", + "scaffold_smiles": "O=C1CCC2(c3ccccc3)SCCN12" + }, + { + "base_idx": 267823, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 33 + ], + "molecule_smiles": "Cc1cc(C)cc(N(CCC#N)C(=O)CSC2=Nc3ccccc3[C@H]3CC=NN23)c1", + "scaffold_smiles": "O=C(CSC1=Nc2ccccc2C2CC=NN12)Nc1ccccc1" + }, + { + "base_idx": 27186, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 43 + ], + "molecule_smiles": "O=C1N=C2C(=CN3N=C(c4ccccc4)O[C@@H]4C(=C5C=CNC=C5)C=CN2[C@@H]43)S1", + "scaffold_smiles": "O=C1N=C2C(=CN3N=C(c4ccccc4)OC4C(=C5C=CNC=C5)C=CN2C43)S1" + }, + { + "base_idx": 222588, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 34 + ], + "molecule_smiles": "Cc1ccc(Nc2nnc(SCc3nc4ccccc4c(=O)[nH]3)s2)cc1", + "scaffold_smiles": "O=c1[nH]c(CSc2nnc(Nc3ccccc3)s2)nc2ccccc12" + }, + { + "base_idx": 271401, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 11, + 14, + 24, + 25, + 26, + 28, + 29, + 30, + 32, + 39 + ], + "molecule_smiles": "Cc1cc(C(=S)N2CCOCC2)c(C)n1C1CCCC1", + "scaffold_smiles": "S=C(c1ccn(C2CCCC2)c1)N1CCOCC1" + }, + { + "base_idx": 527333, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 16, + 17, + 19, + 22, + 25, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "CCOC(=O)c1c(-c2cccc3ccccc23)csc1NC(=O)[C@@H]1[C@H](C(=O)O)[C@H]2CC[C@@H]1O2", + "scaffold_smiles": "O=C(Nc1cc(-c2cccc3ccccc23)cs1)C1CC2CCC1O2" + }, + { + "base_idx": 629122, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 25, + 27, + 28 + ], + "molecule_smiles": "CC1=C(C(=O)O)N2C(=O)[C@H](NC(=O)CCCC(=O)O)[C@H]2SC1", + "scaffold_smiles": "O=C1CC2SCC=CN12" + }, + { + "base_idx": 390057, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "COc1ccc(-c2nnc3c4ccccc4c(C)nn23)cc1", + "scaffold_smiles": "c1ccc(-c2nnc3c4ccccc4cnn23)cc1" + }, + { + "base_idx": 26064, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "O=C1S/C(=C\\c2ccc(Cl)cc2)C(=O)N1CCn1cnnn1", + "scaffold_smiles": "O=C1SC(=Cc2ccccc2)C(=O)N1CCn1cnnn1" + }, + { + "base_idx": 448832, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 29, + 30, + 31, + 32, + 33, + 36 + ], + "molecule_smiles": "CN1CCN([C@@H]2CC(=O)N(Cc3ccc(N4CCCC4=O)cc3)C2=O)CC1", + "scaffold_smiles": "O=C1CC(N2CCNCC2)C(=O)N1Cc1ccc(N2CCCC2=O)cc1" + }, + { + "base_idx": 521945, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 29 + ], + "molecule_smiles": "CCOC(=O)c1ccc(C#Cc2ccc3c(c2)C(C)(C)CCS3)nc1", + "scaffold_smiles": "C(#Cc1ccccn1)c1ccc2c(c1)CCCS2" + }, + { + "base_idx": 178862, + "atom_indices": [ + 1, + 2, + 3, + 7, + 8, + 9, + 11, + 12, + 13 + ], + "molecule_smiles": "CC1=NC(N)=[S+]/C1=C(/[O-])N1N=N1", + "scaffold_smiles": "C1=NC=[S+]C1=CN1N=N1" + }, + { + "base_idx": 599483, + "atom_indices": [ + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CCCC1=NCCn2cccc21", + "scaffold_smiles": "C1=NCCn2cccc21" + }, + { + "base_idx": 440504, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 37, + 38, + 39, + 40, + 42, + 45, + 46, + 47, + 54, + 55, + 56, + 57, + 58, + 59 + ], + "molecule_smiles": "COC(=O)CCCC1=CC2=C(c3ccccc3)C(=O)[C@](C)(OC(=O)C3CCCC3)C(=O)C2=CN1C1CC1", + "scaffold_smiles": "O=C(OC1C(=O)C2=CN(C3CC3)C=CC2=C(c2ccccc2)C1=O)C1CCCC1" + }, + { + "base_idx": 652825, + "atom_indices": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 26 + ], + "molecule_smiles": "CC(C)CN1C(=O)N(C)c2nc3n(c2=C1[O-])CC[S+]=3", + "scaffold_smiles": "O=C1NC=c2c(nc3n2CC[S+]=3)N1" + }, + { + "base_idx": 604290, + "atom_indices": [ + 7, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CC[C@]1(C)OC(=O)N2CCc3c(C)[nH]c4c(C)ccc(c34)[C@]21C", + "scaffold_smiles": "O=C1OCC2c3cccc4[nH]cc(c34)CCN12" + }, + { + "base_idx": 230721, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 32, + 38, + 40 + ], + "molecule_smiles": "Cc1ccc(N2CCN(S(=O)(=O)c3c(C)nc4[s+]cc(C)n4c3[O-])CC2)c(C)c1", + "scaffold_smiles": "O=S(=O)(c1cnc2[s+]ccn2c1)N1CCN(c2ccccc2)CC1" + }, + { + "base_idx": 406677, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 20, + 21, + 22, + 23, + 28, + 29, + 34, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 54 + ], + "molecule_smiles": "COc1cc([C@@H](c2sc3nc(C)nn3c2O)N2C[C@H](C)C[C@H](C)C2)cc(OC)c1OC", + "scaffold_smiles": "c1ccc(C(c2cn3ncnc3s2)N2CCCCC2)cc1" + }, + { + "base_idx": 3074, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 32, + 34 + ], + "molecule_smiles": "c1ccc(CC2CCN(c3nc4ccccc4n4nnnc34)CC2)cc1", + "scaffold_smiles": "c1ccc(CC2CCN(c3nc4ccccc4n4nnnc34)CC2)cc1" + }, + { + "base_idx": 523717, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 37, + 38, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "CCOC(=O)c1c(C)oc2c1c([C@@H](c1cccnc1)N1CCOCC1)c(O)c1ccccc12", + "scaffold_smiles": "c1cncc(C(c2cc3ccccc3c3occc23)N2CCOCC2)c1" + }, + { + "base_idx": 687688, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 30, + 32, + 43, + 44, + 45, + 47, + 48, + 49, + 50, + 51 + ], + "molecule_smiles": "CC(NCCN1CCN(C(C)=C2C(=O)c3ccccc3C2=O)CC1)=C1C(=O)CC(C)(C)CC1=O", + "scaffold_smiles": "O=C1CCCC(=O)C1=CNCCN1CCN(C=C2C(=O)c3ccccc3C2=O)CC1" + }, + { + "base_idx": 49659, + "atom_indices": [ + 4, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 28, + 31 + ], + "molecule_smiles": "O=C(O)[C@@H]1CCCN(S(=O)(=O)c2ccc(-c3ccc(=O)[nH]n3)s2)C1", + "scaffold_smiles": "O=c1ccc(-c2ccc(S(=O)(=O)N3CCCCC3)s2)n[nH]1" + }, + { + "base_idx": 180871, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CC1=NC(=c2ccc(=C([O-])Nc3cccc(Br)c3)cc2)C=[S+]1", + "scaffold_smiles": "C1=NC(=c2ccc(=CNc3ccccc3)cc2)C=[S+]1" + }, + { + "base_idx": 475879, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 34 + ], + "molecule_smiles": "CCc1nc2ccc(C(=O)Nc3nnc(C)s3)cc2nc1CC", + "scaffold_smiles": "O=C(Nc1nncs1)c1ccc2nccnc2c1" + }, + { + "base_idx": 652207, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "CC(C)Cn1[nH]c2c3c(ccc2c1=O)C(=O)c1ccccc1C3=O", + "scaffold_smiles": "O=C1c2ccccc2C(=O)c2c1ccc1c(=O)[nH][nH]c21" + }, + { + "base_idx": 5290, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "S=C(Nc1nc2c(s1)CCCC2)NC1CC1", + "scaffold_smiles": "S=C(Nc1nc2c(s1)CCCC2)NC1CC1" + }, + { + "base_idx": 682990, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 19, + 20, + 21, + 22, + 27, + 28 + ], + "molecule_smiles": "CC(=O)N1c2ccccc2C(C)(C)[C@@H]2CC[C@@H](C)C[C@@H]21", + "scaffold_smiles": "c1ccc2c(c1)CC1CCCCC1N2" + }, + { + "base_idx": 689308, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 20, + 22, + 23, + 24, + 27, + 30, + 31, + 32 + ], + "molecule_smiles": "C=CCn1c(S[C@H]2CCOC2=O)nc2[s+]cc(C3CC3)c-2c1[O-]", + "scaffold_smiles": "O=C1OCCC1Sc1nc2[s+]cc(C3CC3)c-2c[nH]1" + }, + { + "base_idx": 545582, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 22, + 23, + 24 + ], + "molecule_smiles": "CCOC(=O)[C@@H](CC)n1nc(C)n2c(cc3occc32)c1=O", + "scaffold_smiles": "O=c1[nH]ncn2c1cc1occc12" + }, + { + "base_idx": 6025, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "S=C(NCc1ccccc1)N1CCn2cccc2[C@H]1c1ccncc1", + "scaffold_smiles": "S=C(NCc1ccccc1)N1CCn2cccc2C1c1ccncc1" + }, + { + "base_idx": 195718, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "Cc1cccc(Nc2ncnc3onc(-c4cccc(Cl)c4)c23)c1C", + "scaffold_smiles": "c1ccc(Nc2ncnc3onc(-c4ccccc4)c23)cc1" + }, + { + "base_idx": 447294, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 27, + 28, + 29 + ], + "molecule_smiles": "CN1CCc2c(sc3c2C(=O)N[C@@H](/C=C/c2ccco2)N3)C1", + "scaffold_smiles": "O=C1NC(C=Cc2ccco2)Nc2sc3c(c21)CCNC3" + }, + { + "base_idx": 12269, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 28, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "O=c1oc2cc(OCc3ccc(F)cc3F)ccc2cc1-c1ccc2ccccc2n1", + "scaffold_smiles": "O=c1oc2cc(OCc3ccccc3)ccc2cc1-c1ccc2ccccc2n1" + }, + { + "base_idx": 536849, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CCOC(=O)CNC(=O)c1ccc(C#CC2(O)CCCCC2)cc1", + "scaffold_smiles": "C(#CC1CCCCC1)c1ccccc1" + }, + { + "base_idx": 681146, + "atom_indices": [ + 4, + 5, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "[H]/N=C(\\N)N[C@@H]1C=C(C(=O)O)O[C@H]([C@H](O)[C@@H](O)CO)[C@@H]1NC(C)=O", + "scaffold_smiles": "C1=COCCC1" + }, + { + "base_idx": 173625, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "molecule_smiles": "Cc1nn(C)cc1-c1csc(N)n1", + "scaffold_smiles": "c1nc(-c2cn[nH]c2)cs1" + }, + { + "base_idx": 604767, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "CC[C@]1(C)Cc2c([s+]c3[nH]c(=O)[nH]c([O-])c2-3)CS1", + "scaffold_smiles": "O=c1[nH]cc2c3c([s+]c-2[nH]1)CSCC3" + }, + { + "base_idx": 233503, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "Cc1ccc(Cn2ccc(NC(=O)c3noc4c3CCc3ccccc3-4)n2)cc1", + "scaffold_smiles": "O=C(Nc1ccn(Cc2ccccc2)n1)c1noc2c1CCc1ccccc1-2" + }, + { + "base_idx": 280859, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 17, + 18, + 19, + 23, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "CC1=c2c(C(F)(F)F)cc(-c3cccs3)nc2=[S+]/C1=C(/[O-])Nc1nccs1", + "scaffold_smiles": "C(Nc1nccs1)=C1C=c2ccc(-c3cccs3)nc2=[S+]1" + }, + { + "base_idx": 626876, + "atom_indices": [ + 1, + 2, + 3, + 4, + 7, + 9, + 10, + 11, + 13, + 19 + ], + "molecule_smiles": "CC1=CCC[C@@H]2[C@@](C)(CC/C(C)=C/C(=O)O)[C@H](C)CC[C@@]12C", + "scaffold_smiles": "C1=CC2CCCCC2CC1" + }, + { + "base_idx": 648677, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 18, + 22, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 40, + 44, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "CC(C)OC(=O)[C@H](C)N[P@@](=O)(OC[C@H]1O[C@@H](n2ccc(=O)[nH]c2=O)[C@](C)(F)[C@@H]1O)Oc1ccccc1", + "scaffold_smiles": "O=c1ccn(C2CCC(CO[PH](=O)Oc3ccccc3)O2)c(=O)[nH]1" + }, + { + "base_idx": 524536, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20 + ], + "molecule_smiles": "CCOC(=O)c1c(N2C(=O)C=CC2=O)sc(C)c1C", + "scaffold_smiles": "O=C1C=CC(=O)N1c1cccs1" + }, + { + "base_idx": 628620, + "atom_indices": [ + 1, + 2, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CC1=C(C#N)[C@@]2(O)C(=O)c3ccccc3[C@@]2(O)N1", + "scaffold_smiles": "O=C1c2ccccc2C2NC=CC12" + }, + { + "base_idx": 584663, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 17, + 18, + 19 + ], + "molecule_smiles": "CCCCc1cc2c(s1)CCCC/C2=N/O", + "scaffold_smiles": "N=C1CCCCc2sccc21" + }, + { + "base_idx": 102940, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 23, + 24, + 28, + 29, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "O=C(COc1ccc(Cl)cc1)NCC(=O)N1CCC2(CC1)NCCc1[nH]cnc12", + "scaffold_smiles": "O=C(COc1ccccc1)NCC(=O)N1CCC2(CC1)NCCc1[nH]cnc12" + }, + { + "base_idx": 545407, + "atom_indices": [ + 14, + 15, + 16, + 17, + 20, + 29, + 30, + 31 + ], + "molecule_smiles": "CCOC(=O)[C@@](O)(C1C(=O)CC(C)(C)CC1=O)C(F)(F)F", + "scaffold_smiles": "O=C1CCCC(=O)C1" + }, + { + "base_idx": 543289, + "atom_indices": [ + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "CCOC(=O)C1=C(N)Oc2ccccc2[C@]12C(=O)N(CC(=O)OC)C(CC(=O)OC)=C2C(=O)OC", + "scaffold_smiles": "O=C1NC=CC12C=COc1ccccc12" + }, + { + "base_idx": 421880, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "COCCCn1c(=S)[nH]c2c(sc3ccccc32)c1=O", + "scaffold_smiles": "O=c1[nH]c(=S)[nH]c2c1sc1ccccc12" + }, + { + "base_idx": 45253, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 27, + 28, + 29 + ], + "molecule_smiles": "O=C(O)CSc1nnc(Br)n1-c1ccc(C2CC2)c2ccccc12", + "scaffold_smiles": "c1ccc2c(-n3cnnc3)ccc(C3CC3)c2c1" + }, + { + "base_idx": 471160, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 25, + 26, + 27, + 28, + 29, + 30, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 46 + ], + "molecule_smiles": "CCn1c2c(c3ccc(C4=NNC(=O)CC4)cc31)C=NN[C@@H](c1ccccc1)O2", + "scaffold_smiles": "O=C1CCC(c2ccc3c4c([nH]c3c2)OC(c2ccccc2)NN=C4)=NN1" + }, + { + "base_idx": 250536, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "Cc1ccc(N2C=Nc3ccc(C)c4nc(C)cc2c34)cc1", + "scaffold_smiles": "C1=Nc2cccc3nccc(c23)N1c1ccccc1" + }, + { + "base_idx": 3270, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "c1ccc(-n2nc3c(ccc4cnnc43)[nH]2)cc1", + "scaffold_smiles": "c1ccc(-n2nc3c(ccc4cnnc43)[nH]2)cc1" + }, + { + "base_idx": 487851, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 10, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CC[C@@H]1C(=O)OC[C@@H]1Cc1cncn1C", + "scaffold_smiles": "O=C1CC(Cc2cnc[nH]2)CO1" + }, + { + "base_idx": 668392, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "C[C@H](C(=O)OCn1nnc2ccccc2c1=O)N1C(=O)c2ccccc2C1=O", + "scaffold_smiles": "O=C(CN1C(=O)c2ccccc2C1=O)OCn1nnc2ccccc2c1=O" + }, + { + "base_idx": 254723, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "Cc1cc2n(n1)[C@H](c1ccco1)Nc1ccccc1-2", + "scaffold_smiles": "c1coc(C2Nc3ccccc3-c3ccnn32)c1" + }, + { + "base_idx": 60820, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 26, + 28, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "O=C(Nc1ccc(N2CCN(C(=O)c3cccs3)CC2)c(Cl)c1)c1ccco1", + "scaffold_smiles": "O=C(Nc1ccc(N2CCN(C(=O)c3cccs3)CC2)cc1)c1ccco1" + }, + { + "base_idx": 441448, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 34, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "COC(=O)C1=C(CSc2nccn2C)NC(=O)N[C@@H]1c1cc(C)ccc1C", + "scaffold_smiles": "O=C1NC(CSc2ncc[nH]2)=CC(c2ccccc2)N1" + }, + { + "base_idx": 181519, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 24, + 27, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "Cc1coc2cc3oc(=O)c(CCC(=O)NCCCn4ccnc4)c(C)c3cc12", + "scaffold_smiles": "O=C(CCc1cc2cc3ccoc3cc2oc1=O)NCCCn1ccnc1" + }, + { + "base_idx": 443904, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 19 + ], + "molecule_smiles": "CNC1=[S+]/C(=[S+]\\c2ncccc2N([O-])[O-])N=N1", + "scaffold_smiles": "C1=[S+]C(=[S+]c2ccccn2)N=N1" + }, + { + "base_idx": 425322, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 19 + ], + "molecule_smiles": "COC[C@H](C)n1c([O-])c2ccccc2[s+]c1=O", + "scaffold_smiles": "O=c1[nH]cc2ccccc2[s+]1" + }, + { + "base_idx": 690404, + "atom_indices": [ + 3, + 4, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "C=CCn1c(N)c(C(=O)OCC(C)C)c2nc3ccccc3nc21", + "scaffold_smiles": "c1ccc2nc3[nH]ccc3nc2c1" + }, + { + "base_idx": 70816, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 23 + ], + "molecule_smiles": "O=C(NCCO)c1scc2c1OCCO2", + "scaffold_smiles": "c1scc2c1OCCO2" + }, + { + "base_idx": 627835, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 22, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "CC1=NN(c2ccc(C)c(C)c2)C(=O)/C1=N\\Nc1cccc(-c2cccc(C(=O)O)c2)c1O", + "scaffold_smiles": "O=C1C(=NNc2cccc(-c3ccccc3)c2)C=NN1c1ccccc1" + }, + { + "base_idx": 692015, + "atom_indices": [ + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 26, + 30, + 31, + 32 + ], + "molecule_smiles": "C=CCSC1=C(C#N)C2(CCCCC2)[C@H](C#N)C(=O)N1", + "scaffold_smiles": "O=C1CC2(C=CN1)CCCCC2" + }, + { + "base_idx": 555473, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 37 + ], + "molecule_smiles": "CCN1C/C(=N\\S(=O)(=O)/N=C2\\CN(CC)C(=O)N2CC)N(CC)C1=O", + "scaffold_smiles": "O=C1NCC(=NS(=O)(=O)N=C2CNC(=O)N2)N1" + }, + { + "base_idx": 556607, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 16, + 17, + 23 + ], + "molecule_smiles": "CCN(CC)c1nc2c(s1)C(=O)NCCC2", + "scaffold_smiles": "O=C1NCCCc2ncsc21" + }, + { + "base_idx": 280124, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 16, + 17, + 19, + 24, + 25, + 26, + 27, + 28, + 29, + 35, + 36, + 37, + 38, + 39, + 50, + 51 + ], + "molecule_smiles": "Cc1c(CN2CCN(C(=O)[C@H](C)O)CC2)sc2c(N3CCOCC3)nc(-c3cnc(N)nc3)nc12", + "scaffold_smiles": "c1ncc(-c2nc(N3CCOCC3)c3sc(CN4CCNCC4)cc3n2)cn1" + }, + { + "base_idx": 699933, + "atom_indices": [ + 0, + 1, + 3, + 4, + 5, + 6, + 8, + 9, + 14, + 16, + 17, + 19, + 20 + ], + "molecule_smiles": "C1C[C@@H]2NCC[C@@H]3NCC[C@H](N1)N32", + "scaffold_smiles": "C1CC2NCCC3NCCC(N1)N32" + }, + { + "base_idx": 664545, + "atom_indices": [ + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 27, + 29, + 30, + 31, + 32, + 33, + 35, + 38, + 51, + 54, + 57 + ], + "molecule_smiles": "CC(C)(C)OC(=O)N1CCC[C@@H]1C(=O)N1CCC[C@@H]1C(=O)N1CCC[C@H]1C(=O)O", + "scaffold_smiles": "O=C(C1CCCN1C(=O)C1CCCN1)N1CCCC1" + }, + { + "base_idx": 370610, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "COc1ccc(C2=NO[C@H](Cn3nc(C(=O)O)cc3-c3ccccc3)C2)cc1", + "scaffold_smiles": "c1ccc(C2=NOC(Cn3nccc3-c3ccccc3)C2)cc1" + }, + { + "base_idx": 51160, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 35 + ], + "molecule_smiles": "O=C(Nc1nc2ccccc2s1)c1cc2c(s1)-c1ccccc1OC2", + "scaffold_smiles": "O=C(Nc1nc2ccccc2s1)c1cc2c(s1)-c1ccccc1OC2" + }, + { + "base_idx": 193668, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 59, + 60, + 61 + ], + "molecule_smiles": "Cc1cccc2c1NC(=O)[C@@]21N2CCC[C@@H]2[C@@H](C(=O)c2ccc3ccccc3c2)[C@]12C(=O)Nc1ccccc12", + "scaffold_smiles": "O=C(c1ccc2ccccc2c1)C1C2CCCN2C2(C(=O)Nc3ccccc32)C12C(=O)Nc1ccccc12" + }, + { + "base_idx": 262781, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 14, + 15, + 16, + 18, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "Cc1cc(N2CCN(C/C=C/c3ccccc3)CC2)n2ncnc2n1", + "scaffold_smiles": "C(=Cc1ccccc1)CN1CCN(c2ccnc3ncnn23)CC1" + }, + { + "base_idx": 675589, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 21, + 24, + 25, + 27, + 28, + 29, + 30, + 33, + 36, + 50, + 51 + ], + "molecule_smiles": "CC(=O)O[C@]1(C)C(=O)OC/C2=C/CN(C)CC[C@@H](OC(=O)[C@@](O)([C@H](C)O)C[C@H]1C)C2=O", + "scaffold_smiles": "O=C1CCCCC(=O)OC2CCNCC=C(CO1)C2=O" + }, + { + "base_idx": 603112, + "atom_indices": [ + 5, + 6, + 7, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 42, + 43, + 50, + 51, + 52, + 53, + 54, + 55 + ], + "molecule_smiles": "CCC1=C2CC[C@H]3C(=O)N(c4ccccc4)C(=O)[C@H]3[C@H]2[C@@]2(C)/C(=N/Cc3ccccc3)OC(=O)N2C1=O", + "scaffold_smiles": "O=C1C2CCC3=CC(=O)N4C(=O)OC(=NCc5ccccc5)C4C3C2C(=O)N1c1ccccc1" + }, + { + "base_idx": 633958, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 19, + 20, + 21, + 22, + 23, + 24, + 28, + 29, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CC1(C)CC(=O)C([C@H](C2=C(O)CC(C)(C)CC2=O)c2cccs2)C(=O)C1", + "scaffold_smiles": "O=C1CCCC=C1C(c1cccs1)C1C(=O)CCCC1=O" + }, + { + "base_idx": 123893, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 15, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 33, + 36, + 37, + 38, + 39, + 42, + 45 + ], + "molecule_smiles": "O=C(CCC1CCCC1)/N=C1\\S[C@@H]2CS(=O)(=O)C[C@@H]2N1Cc1ccccc1", + "scaffold_smiles": "O=C(CCC1CCCC1)N=C1SC2CS(=O)(=O)CC2N1Cc1ccccc1" + }, + { + "base_idx": 554850, + "atom_indices": [ + 5, + 6, + 7, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "CCN1CCC[C@H]1CNC(=O)Cn1nc(C)n2c(cc3cc(F)ccc32)c1=O", + "scaffold_smiles": "O=C(Cn1ncn2c(cc3ccccc32)c1=O)NCC1CCCN1" + }, + { + "base_idx": 19196, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 24 + ], + "molecule_smiles": "O=S1(=O)N[C@@H]([C@@H]2C[C@H]2c2ccccc2)CCO1", + "scaffold_smiles": "O=S1(=O)NC(C2CC2c2ccccc2)CCO1" + }, + { + "base_idx": 561879, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CCN(CC)/N=N/c1ccc([S@](=O)([O-])=Nc2nc(C)cc(C)n2)cc1", + "scaffold_smiles": "O=[SH](=Nc1ncccn1)c1ccccc1" + }, + { + "base_idx": 144365, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "N#Cc1cc2c(nc1S[C@H]1CCCC[C@@H]1O)CCCC2", + "scaffold_smiles": "c1cc2c(nc1SC1CCCCC1)CCCC2" + }, + { + "base_idx": 210158, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 34, + 36 + ], + "molecule_smiles": "Cc1ccc2c(c1)N(C(=O)CCC(=O)N1CCN(c3ccccn3)CC1)CCO2", + "scaffold_smiles": "O=C(CCC(=O)N1CCOc2ccccc21)N1CCN(c2ccccn2)CC1" + }, + { + "base_idx": 146045, + "atom_indices": [ + 2, + 3, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "N#CC1=C(N)N(c2ccccc2Sc2ccccc2)C(=O)C1", + "scaffold_smiles": "O=C1CC=CN1c1ccccc1Sc1ccccc1" + }, + { + "base_idx": 27488, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 25, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "O=C1NC(=O)[C@H]2[C@@H]1[C@@H](C(=O)c1cccs1)N1C=Cc3ccccc3[C@H]21", + "scaffold_smiles": "O=C1NC(=O)C2C1C(C(=O)c1cccs1)N1C=Cc3ccccc3C21" + }, + { + "base_idx": 570572, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 17, + 20, + 23, + 27, + 28 + ], + "molecule_smiles": "CCCn1c(=O)c2nc(C3CCCC3)[nH]c2n(CCC)c1=O", + "scaffold_smiles": "O=c1[nH]c(=O)c2nc(C3CCCC3)[nH]c2[nH]1" + }, + { + "base_idx": 41429, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 22, + 24 + ], + "molecule_smiles": "O=C(O)c1ccccc1N[S@+]1ON([O-])c2ccccc21", + "scaffold_smiles": "c1ccc(N[S+]2ONc3ccccc32)cc1" + }, + { + "base_idx": 650700, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CC(C)NC(=O)CSc1nnc2sc3ccccc3n12", + "scaffold_smiles": "c1ccc2c(c1)sc1nncn12" + }, + { + "base_idx": 697126, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 16, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "C=C1CSc2nc([O-])c3c(C)c(C)[s+]c-3n21", + "scaffold_smiles": "C=C1CSc2ncc3cc[s+]c-3n21" + }, + { + "base_idx": 170057, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 12, + 17, + 18, + 19, + 22, + 31 + ], + "molecule_smiles": "Cc1noc(C(=O)N2C[C@@]3(C)C[C@@H]2CC(C)(C)C3)c1Cl", + "scaffold_smiles": "O=C(c1ccno1)N1CC2CCCC1C2" + }, + { + "base_idx": 44651, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "O=C(O)c1ccc(/C=N/N2CCN(C3c4ccccc4-c4ccccc43)CC2)cc1", + "scaffold_smiles": "C(=NN1CCN(C2c3ccccc3-c3ccccc32)CC1)c1ccccc1" + }, + { + "base_idx": 5169, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "S=c1[nH]c(-c2ccccc2)cn1/N=C/c1cccs1", + "scaffold_smiles": "S=c1[nH]c(-c2ccccc2)cn1N=Cc1cccs1" + }, + { + "base_idx": 201814, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "Cc1cccc([C@@H]2c3[nH]c4ccccc4c3CCN2C(=O)c2cc3c([nH]c2=O)CCCC3)n1", + "scaffold_smiles": "O=C(c1cc2c([nH]c1=O)CCCC2)N1CCc2c([nH]c3ccccc23)C1c1ccccn1" + }, + { + "base_idx": 699663, + "atom_indices": [ + 0, + 1, + 2, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 25, + 29, + 30 + ], + "molecule_smiles": "C1CCC(c2nn3c(-c4n[nH]c5c4CCC5)nnc3s2)CC1", + "scaffold_smiles": "C1CCC(c2nn3c(-c4n[nH]c5c4CCC5)nnc3s2)CC1" + }, + { + "base_idx": 14820, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 14, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "O=c1c(-c2ccc(F)cc2)coc2c3c(ccc12)OCCO3", + "scaffold_smiles": "O=c1c(-c2ccccc2)coc2c3c(ccc12)OCCO3" + }, + { + "base_idx": 658489, + "atom_indices": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 29, + 30, + 31, + 37, + 38 + ], + "molecule_smiles": "CC(C)[C@H]1Cc2c([s+]c3n4cnnc4n(-c4ccccc4)c([O-])c2-3)CO1", + "scaffold_smiles": "c1ccc(-n2cc3c4c([s+]c-3n3cnnc23)COCC4)cc1" + }, + { + "base_idx": 460935, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 17, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 36, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 57, + 60 + ], + "molecule_smiles": "CN(C)[C@@H]1C(=O)[C@@H](C(=O)NCN2CCCC2)C(=O)[C@@]2(O)C(=O)[C@@H]3C(=O)c4c(O)cccc4[C@@](C)(O)[C@H]3C[C@@H]12", + "scaffold_smiles": "O=C1CC2CC3Cc4ccccc4C(=O)C3C(=O)C2C(=O)C1C(=O)NCN1CCCC1" + }, + { + "base_idx": 591621, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCCCN(CCCC)C1=Nc2ccccc2N/C1=C(/C#N)C(=O)OCC", + "scaffold_smiles": "C=C1C=Nc2ccccc2N1" + }, + { + "base_idx": 145701, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 46, + 47, + 48, + 49, + 50, + 51 + ], + "molecule_smiles": "N#Cc1c(NC(=O)CSc2n[nH]c(Cc3ccccc3)n2)oc(-c2ccccc2)c1-c1ccccc1", + "scaffold_smiles": "O=C(CSc1n[nH]c(Cc2ccccc2)n1)Nc1cc(-c2ccccc2)c(-c2ccccc2)o1" + }, + { + "base_idx": 538898, + "atom_indices": [ + 10, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CCOC(=O)C[C@@]1([O-])C[S+]=C2C(C)=Cc3cc(OC)ccc3N21", + "scaffold_smiles": "C1=Cc2ccccc2N2CC[S+]=C12" + }, + { + "base_idx": 170857, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 30 + ], + "molecule_smiles": "Cc1nnc(/[S+]=C(/[O-])N2c3ccccc3Sc3ccccc32)s1", + "scaffold_smiles": "C(=[S+]c1nncs1)N1c2ccccc2Sc2ccccc21" + }, + { + "base_idx": 571631, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "CCCc1nc2c(=O)n(C)c(=O)nc-2n(C)n1", + "scaffold_smiles": "O=c1nc2[nH]ncnc-2c(=O)[nH]1" + }, + { + "base_idx": 4213, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "c1cc(-c2nnn(CCOCCn3nnc(-c4ccncc4)n3)n2)ccn1", + "scaffold_smiles": "c1cc(-c2nnn(CCOCCn3nnc(-c4ccncc4)n3)n2)ccn1" + }, + { + "base_idx": 696749, + "atom_indices": [ + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 13, + 15, + 16, + 19, + 22, + 28, + 29, + 30, + 32, + 33, + 35, + 36, + 37, + 39, + 40, + 43, + 46, + 49, + 53 + ], + "molecule_smiles": "C=CCN1C(=O)[C@H]2[C@H](C(=O)NC3CCCC3)[C@@H]3C=C[C@]2(O3)[C@@H]1C(=O)NC1CCCC1", + "scaffold_smiles": "O=C(NC1CCCC1)C1C2C=CC3(O2)C(C(=O)NC2CCCC2)NC(=O)C13" + }, + { + "base_idx": 676944, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CC(=O)Nc1ccc(S(=O)(=O)Oc2ccc3c(c2)C(C)=CC(C)(C)N3)cc1", + "scaffold_smiles": "O=S(=O)(Oc1ccc2c(c1)C=CCN2)c1ccccc1" + }, + { + "base_idx": 255061, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Cc1cc2c(c(=O)o1)[C@@H](Cc1ccccc1)C(C#N)=C(N)O2", + "scaffold_smiles": "O=c1occc2c1C(Cc1ccccc1)C=CO2" + }, + { + "base_idx": 522345, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 30 + ], + "molecule_smiles": "CCOC(=O)c1cc(CNC(=O)c2c(C)oc3c2CCCC3)c(C)o1", + "scaffold_smiles": "O=C(NCc1ccoc1)c1coc2c1CCCC2" + }, + { + "base_idx": 273010, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 22, + 23, + 24, + 26, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "Cc1cc(C(=O)N[C@@H](c2ccccc2)[C@]2(C)C[C@H]2[C@@H](C)C(=O)Nc2ccc3ccccc3c2)no1", + "scaffold_smiles": "O=C(CC1CC1C(NC(=O)c1ccon1)c1ccccc1)Nc1ccc2ccccc2c1" + }, + { + "base_idx": 55265, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "O=C(Nc1cccc2c1C(=O)NC2=O)c1ccc(N2C(=O)CCC2=O)cc1", + "scaffold_smiles": "O=C(Nc1cccc2c1C(=O)NC2=O)c1ccc(N2C(=O)CCC2=O)cc1" + }, + { + "base_idx": 24644, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "O=C1c2ccccc2S(=O)(=O)N1C[C@H](O)Cn1ccc2ccccc21", + "scaffold_smiles": "O=C1c2ccccc2S(=O)(=O)N1CCCn1ccc2ccccc21" + }, + { + "base_idx": 667313, + "atom_indices": [ + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 27, + 28, + 29, + 30, + 31, + 33, + 35, + 38, + 43 + ], + "molecule_smiles": "CC(C)(C)[C@H]1CCC2=C(C1)[S+]=C(NC(=O)[C@@H]1CCCO1)/C2=C(\\N)[O-]", + "scaffold_smiles": "C=C1C(NC(=O)C2CCCO2)=[S+]C2=C1CCCC2" + }, + { + "base_idx": 524330, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 43, + 44, + 45, + 50 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)/C(=C(/C)[O-])[S+]=C1NC(=O)C/[S+]=c1\\nc2ccccc2c([O-])n1-c1ccccc1", + "scaffold_smiles": "C=C1C=CC(NC(=O)C[S+]=c2nc3ccccc3cn2-c2ccccc2)=[S+]1" + }, + { + "base_idx": 165482, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 23, + 24, + 25, + 26, + 27, + 28, + 36 + ], + "molecule_smiles": "Cc1sc2nc(CN(Cc3cccs3)C(=O)C3CC3)[nH]c(=O)c2c1C", + "scaffold_smiles": "O=C(C1CC1)N(Cc1nc2sccc2c(=O)[nH]1)Cc1cccs1" + }, + { + "base_idx": 275716, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 30 + ], + "molecule_smiles": "Cc1cc(=O)[nH]c(SCC(=O)[C@]23C[C@H]4C[C@H](C[C@H](C4)C2)C3)c1C#N", + "scaffold_smiles": "O=C(CSc1cccc(=O)[nH]1)C12CC3CC(CC(C3)C1)C2" + }, + { + "base_idx": 426663, + "atom_indices": [ + 6, + 7, + 9, + 12, + 13, + 14, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 29, + 30, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "COCCC(=O)N1CCC(Oc2ccc(C(=O)N(C)[C@H](C)c3ccon3)cc2)CC1", + "scaffold_smiles": "O=C(NCc1ccon1)c1ccc(OC2CCNCC2)cc1" + }, + { + "base_idx": 177574, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 27 + ], + "molecule_smiles": "Cc1nc2ccccn2c1C(=O)NN=C1[C@H]2C[C@@H]3C[C@@H](C[C@H]1C3)C2", + "scaffold_smiles": "O=C(NN=C1C2CC3CC(C2)CC1C3)c1cnc2ccccn12" + }, + { + "base_idx": 178218, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 51, + 52 + ], + "molecule_smiles": "Cc1nc2c([nH]1)-c1ccccc1N(C(=O)c1ccc(NC(=O)c3ccccc3-c3ccccc3)cc1)CC2", + "scaffold_smiles": "O=C(Nc1ccc(C(=O)N2CCc3nc[nH]c3-c3ccccc32)cc1)c1ccccc1-c1ccccc1" + }, + { + "base_idx": 524766, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "CCOC(=O)c1c(C)n(C)c2ccc(OC(=O)c3ccoc3)cc12", + "scaffold_smiles": "O=C(Oc1ccc2[nH]ccc2c1)c1ccoc1" + }, + { + "base_idx": 145967, + "atom_indices": [ + 2, + 3, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 23, + 24, + 25, + 26, + 27, + 29, + 31, + 32, + 34, + 42, + 43, + 44, + 45, + 46, + 47 + ], + "molecule_smiles": "N#CC1=C(N)Oc2n[nH]c(-c3ccccc3)c2[C@H]1c1cccc(OC(=O)N2CCOCC2)c1", + "scaffold_smiles": "O=C(Oc1cccc(C2C=COc3n[nH]c(-c4ccccc4)c32)c1)N1CCOCC1" + }, + { + "base_idx": 33640, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21 + ], + "molecule_smiles": "O=C(c1ccccc1)n1c(=O)c(F)cn(COCCBr)c1=O", + "scaffold_smiles": "O=C(c1ccccc1)n1c(=O)cc[nH]c1=O" + }, + { + "base_idx": 452628, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CN1C(=O)c2cccc3c(S(=O)(=O)Nc4ccccc4F)ccc1c23", + "scaffold_smiles": "O=C1Nc2ccc(S(=O)(=O)Nc3ccccc3)c3cccc1c23" + }, + { + "base_idx": 217490, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "Cc1ccc(S(=O)(=O)N/N=C/c2cccn2-c2ccc(Cl)cc2)cc1", + "scaffold_smiles": "O=S(=O)(NN=Cc1cccn1-c1ccccc1)c1ccccc1" + }, + { + "base_idx": 512485, + "atom_indices": [ + 5, + 6, + 7, + 9, + 10, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 23, + 34, + 36, + 37, + 38, + 39, + 40, + 41, + 44, + 45, + 46, + 49 + ], + "molecule_smiles": "CCOP(=O)(N[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2)N[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2", + "scaffold_smiles": "O=[PH](NC12CC3CC(CC(C3)C1)C2)NC12CC3CC(CC(C3)C1)C2" + }, + { + "base_idx": 160605, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "molecule_smiles": "Cn1[nH]c(C(F)(F)F)cc1=O", + "scaffold_smiles": "O=c1cc[nH][nH]1" + }, + { + "base_idx": 12652, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 19, + 22, + 23, + 24, + 27 + ], + "molecule_smiles": "O=c1cc(O)c2sc3nc(C(F)(F)F)c4c(c3c2[nH]1)CCCCC4", + "scaffold_smiles": "O=c1ccc2sc3ncc4c(c3c2[nH]1)CCCCC4" + }, + { + "base_idx": 5011, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "S=c1[nH]nc(COc2ccccc2)n1/N=C/C=C/c1ccco1", + "scaffold_smiles": "S=c1[nH]nc(COc2ccccc2)n1N=CC=Cc1ccco1" + }, + { + "base_idx": 41753, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "O=C(O)c1oc2ccc(Br)cc2c1-n1cccc1", + "scaffold_smiles": "c1ccc2c(-n3cccc3)coc2c1" + }, + { + "base_idx": 777, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 13, + 14, + 15, + 18, + 19 + ], + "molecule_smiles": "c1ccc2cc(C3=NCCN3)ncc2c1", + "scaffold_smiles": "c1ccc2cc(C3=NCCN3)ncc2c1" + }, + { + "base_idx": 588770, + "atom_indices": [ + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "CCCCNC(=S)NNC(=O)CCn1nc(C)c(Br)c1C", + "scaffold_smiles": "c1cn[nH]c1" + }, + { + "base_idx": 129205, + "atom_indices": [ + 1, + 2, + 3, + 4, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "Nc1nc2ncc(-c3ccsc3)c(N)n2n1", + "scaffold_smiles": "c1nc2ncc(-c3ccsc3)cn2n1" + }, + { + "base_idx": 255387, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 24, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "Cc1cc2c(cc1C)N(C(=O)CSC1=NCCS1)C(C)(C)c1ssc(=S)c1-2", + "scaffold_smiles": "O=C(CSC1=NCCS1)N1Cc2ssc(=S)c2-c2ccccc21" + }, + { + "base_idx": 643371, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "CC(C)n1/c(=N\\C(=O)c2ccco2)c(C#N)cc2c(=O)n3ccccc3cc21", + "scaffold_smiles": "O=C(N=c1ccc2c(=O)n3ccccc3cc2[nH]1)c1ccco1" + }, + { + "base_idx": 453601, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 36 + ], + "molecule_smiles": "CN(NC(=O)[C@@H]1CC(=O)N(c2ccccc2)C1)C1=NS(=O)(=O)c2ccccc21", + "scaffold_smiles": "O=C(NNC1=NS(=O)(=O)c2ccccc21)C1CC(=O)N(c2ccccc2)C1" + }, + { + "base_idx": 307234, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28, + 29, + 30, + 34, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "COc1ccccc1CN(Cc1cccnc1)S(=O)(=O)c1ccc(-n2cnnn2)c(OC)c1", + "scaffold_smiles": "O=S(=O)(c1ccc(-n2cnnn2)cc1)N(Cc1ccccc1)Cc1cccnc1" + }, + { + "base_idx": 161138, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 25, + 26, + 27, + 30 + ], + "molecule_smiles": "Cn1c(=O)c2c(n3cnnc13)-c1ccccc1CC21CCCC1", + "scaffold_smiles": "O=c1[nH]c2nncn2c2c1C1(CCCC1)Cc1ccccc1-2" + }, + { + "base_idx": 177059, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 32 + ], + "molecule_smiles": "Cc1nc2ccccc2n1CCc1nc2c3ccccc3nc(=S)n2[nH]1", + "scaffold_smiles": "S=c1nc2ccccc2c2nc(CCn3cnc4ccccc43)[nH]n12" + }, + { + "base_idx": 153447, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "Cn1cccc1C(=O)NC(=O)COC(=O)Cc1coc2ccc3ccccc3c12", + "scaffold_smiles": "O=C(COC(=O)Cc1coc2ccc3ccccc3c12)NC(=O)c1ccc[nH]1" + }, + { + "base_idx": 13291, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 26, + 27, + 28, + 29, + 35, + 38, + 41, + 42, + 43, + 44, + 45, + 48, + 56 + ], + "molecule_smiles": "O=c1cc(-c2ccc(O)cc2)oc2cc(O[C@@H]3O[C@H](CO)[C@@H](O)[C@H](O)[C@H]3O[C@@H]3OC[C@](O)(CO)[C@H]3O)cc(O)c12", + "scaffold_smiles": "O=c1cc(-c2ccccc2)oc2cc(OC3OCCCC3OC3CCCO3)ccc12" + }, + { + "base_idx": 276258, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "Cc1cc(-c2ncc(CC(=O)Nc3ccc(-c4cnccn4)cn3)cc2C)ccn1", + "scaffold_smiles": "O=C(Cc1ccc(-c2ccncc2)nc1)Nc1ccc(-c2cnccn2)cn1" + }, + { + "base_idx": 633760, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 15, + 17, + 18 + ], + "molecule_smiles": "CC1(C)C(CO)=CC([O-])=C2NC(=O)C[S+]=C21", + "scaffold_smiles": "O=C1C[S+]=C2CC=CC=C2N1" + }, + { + "base_idx": 265221, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "Cc1cc(C)cc(Oc2nc(C(F)(F)F)nc3ccccc23)c1", + "scaffold_smiles": "c1ccc(Oc2ncnc3ccccc23)cc1" + }, + { + "base_idx": 548418, + "atom_indices": [ + 6, + 7, + 8, + 10, + 13, + 16, + 23, + 24, + 26 + ], + "molecule_smiles": "CCNC1=N[C@@H]2[C@@H](O)[C@H](O)[C@@H](CO)O[C@@H]2S1", + "scaffold_smiles": "C1=NC2CCCOC2S1" + }, + { + "base_idx": 281915, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "Cc1[nH]ccc2c1[nH]c1cc(=O)ccc12", + "scaffold_smiles": "O=c1ccc2c(c1)[nH]c1c[nH]ccc12" + }, + { + "base_idx": 451167, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 35, + 37, + 40 + ], + "molecule_smiles": "CN1[C@@H]2CC[C@H]1/C(=C/c1cccn1C)C(=O)/C2=C\\c1cccn1C", + "scaffold_smiles": "O=C1C(=Cc2ccc[nH]2)C2CCC(N2)C1=Cc1ccc[nH]1" + }, + { + "base_idx": 28912, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "O=C1CCC[C@@H]2[C@H]3C[C@@H](CN12)[C@H]1CCCCN1C3", + "scaffold_smiles": "O=C1CCCC2C3CC(CN12)C1CCCCN1C3" + }, + { + "base_idx": 220528, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 42, + 43 + ], + "molecule_smiles": "Cc1ccc(Oc2ccc3c(c2)C(=O)N(c2ccc(S(=O)(=O)Nc4nccs4)cc2)C3=O)cc1", + "scaffold_smiles": "O=C1c2ccc(Oc3ccccc3)cc2C(=O)N1c1ccc(S(=O)(=O)Nc2nccs2)cc1" + }, + { + "base_idx": 369430, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 14, + 17, + 19, + 22, + 25, + 29, + 30, + 31, + 32, + 33, + 34, + 36 + ], + "molecule_smiles": "COc1ccc(C2CNC(=O)NC2)cc1O[C@H]1C[C@@H]2CC[C@H]1C2", + "scaffold_smiles": "O=C1NCC(c2cccc(OC3CC4CCC3C4)c2)CN1" + }, + { + "base_idx": 471729, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 29, + 30, + 41, + 42, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 54 + ], + "molecule_smiles": "CCn1c(SCC(=O)N2CCCC[C@@H]2C)nnc1-c1ccc(S(=O)(=O)N2CCN(C)CC2)cc1", + "scaffold_smiles": "O=C(CSc1nnc(-c2ccc(S(=O)(=O)N3CCNCC3)cc2)[nH]1)N1CCCCC1" + }, + { + "base_idx": 682379, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15 + ], + "molecule_smiles": "CC(=O)N[C@]1(C(F)(F)F)N=C2SCCN2C1=O", + "scaffold_smiles": "O=C1CN=C2SCCN12" + }, + { + "base_idx": 208986, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Cc1ccc2c(ccn3cc4c(c(=O)n(C)c(=O)n4C)c23)c1", + "scaffold_smiles": "O=c1[nH]c(=O)c2c(cn3ccc4ccccc4c23)[nH]1" + }, + { + "base_idx": 262747, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 11, + 12, + 13, + 14, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "Cc1cc(N2CCN(Cc3cn(C)nc3-c3cccc(Cl)c3)CC2)cc(C)n1", + "scaffold_smiles": "c1ccc(-c2n[nH]cc2CN2CCN(c3ccncc3)CC2)cc1" + }, + { + "base_idx": 263039, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "Cc1cc(C)nc(Nc2n[nH]c(C)n2)n1", + "scaffold_smiles": "c1cnc(Nc2nc[nH]n2)nc1" + }, + { + "base_idx": 432498, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 20, + 21, + 23, + 29, + 30, + 31, + 33, + 34, + 36, + 44 + ], + "molecule_smiles": "COC(=O)c1cc2c(s1)=NC=NP=2(N1CCOCC1)N1CCOCC1", + "scaffold_smiles": "C1=NP(N2CCOCC2)(N2CCOCC2)=c2ccsc2=N1" + }, + { + "base_idx": 520580, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CCOC(=O)c1cccc2c1-c1ccccc1/C2=N/O", + "scaffold_smiles": "N=C1c2ccccc2-c2ccccc21" + }, + { + "base_idx": 73635, + "atom_indices": [ + 0, + 1, + 2, + 4, + 6, + 7, + 8, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 41, + 42, + 43, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "O=C(NCCCCCCNC(=O)O[C@H]1CCS(=O)(=O)C1)O[C@H]1CCS(=O)(=O)C1", + "scaffold_smiles": "O=C(NCCCCCCNC(=O)OC1CCS(=O)(=O)C1)OC1CCS(=O)(=O)C1" + }, + { + "base_idx": 632890, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 22, + 23, + 24, + 25, + 27, + 30, + 31, + 32 + ], + "molecule_smiles": "CC1(C)CC(=O)c2cnc(NC(=O)C3CCCC3)nc2C1", + "scaffold_smiles": "O=C1CCCc2nc(NC(=O)C3CCCC3)ncc21" + }, + { + "base_idx": 160681, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "CN1C2=NC=C3SC=C[C@H]3N2c2ccccc21", + "scaffold_smiles": "C1=CC2C(=CN=C3Nc4ccccc4N32)S1" + }, + { + "base_idx": 239418, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 26 + ], + "molecule_smiles": "Cc1ccc(C)n1-n1c(C)nc2[s+]cc(-c3ccccc3)c-2c1[O-]", + "scaffold_smiles": "c1ccc(-c2c[s+]c3ncn(-n4cccc4)cc2-3)cc1" + }, + { + "base_idx": 135297, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 23 + ], + "molecule_smiles": "Nc1nc2c(s1)C=C[C@@H](c1ccccc1)O2", + "scaffold_smiles": "C1=CC(c2ccccc2)Oc2ncsc21" + }, + { + "base_idx": 633743, + "atom_indices": [ + 4, + 5, + 6, + 8, + 9, + 12, + 13, + 21, + 24, + 25, + 27, + 30, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "CC1(C)/C(=C2/C=CC=[S+]2)N([O-])[C@]2(CCCCC/C2=N\\NC(N)=S)N1O", + "scaffold_smiles": "N=C1CCCCCC12NCC(=C1C=CC=[S+]1)N2" + }, + { + "base_idx": 225685, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25, + 26, + 27, + 31, + 33, + 35, + 36, + 39, + 42, + 45, + 49, + 50, + 51, + 52, + 53 + ], + "molecule_smiles": "Cc1ccc(NC(=O)c2c(C)nc3c(c2-c2ccco2)=C(N)/C(=C(\\[O-])NC2CCCC2)[S+]=3)c(C)c1", + "scaffold_smiles": "O=C(Nc1ccccc1)c1cnc2c(c1-c1ccco1)=CC(=CNC1CCCC1)[S+]=2" + }, + { + "base_idx": 31576, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 13, + 14, + 17, + 20, + 22, + 25 + ], + "molecule_smiles": "O=C1/C(=C\\c2ccsc2)N2CCC1CC2", + "scaffold_smiles": "O=C1C(=Cc2ccsc2)N2CCC1CC2" + }, + { + "base_idx": 396772, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 38 + ], + "molecule_smiles": "COc1cc2c(cc1OC)-c1cc(N3CCc4ccccc43)nc(=O)n1CC2", + "scaffold_smiles": "O=c1nc(N2CCc3ccccc32)cc2n1CCc1ccccc1-2" + }, + { + "base_idx": 259249, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 25 + ], + "molecule_smiles": "Cc1cc(NC(=O)CSc2nnc(-c3ccc(Cl)s3)o2)no1", + "scaffold_smiles": "O=C(CSc1nnc(-c2cccs2)o1)Nc1ccon1" + }, + { + "base_idx": 628619, + "atom_indices": [ + 1, + 2, + 5, + 6, + 7, + 8, + 12 + ], + "molecule_smiles": "CC1=C(C#N)[C@@H]2[C@H](/C1=N/O)C2(C)C", + "scaffold_smiles": "N=C1C=CC2CC12" + }, + { + "base_idx": 186185, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 22 + ], + "molecule_smiles": "Cc1cccn2cc(-c3ccc4c(c3)NC(=O)CO4)nc12", + "scaffold_smiles": "O=C1COc2ccc(-c3cn4ccccc4n3)cc2N1" + }, + { + "base_idx": 460609, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 9, + 12, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 30, + 31, + 32, + 34, + 36 + ], + "molecule_smiles": "CN(C)[P@](=O)(/C(=N/c1ccccc1)N1CCCC1)N1CCCC1", + "scaffold_smiles": "O=[PH](C(=Nc1ccccc1)N1CCCC1)N1CCCC1" + }, + { + "base_idx": 144124, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "N#CC1=CC2=C([O-])CCCC2=N/C1=[S+]\\Cc1ccccc1", + "scaffold_smiles": "C1=CC(=[S+]Cc2ccccc2)N=C2CCCC=C12" + }, + { + "base_idx": 269406, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "Cc1cc(C)c2c(n1)=[S+]C1=C([O-])N(c3ccccc3C)CC(=O)NC=21", + "scaffold_smiles": "O=C1CN(c2ccccc2)C=C2[S+]=c3ncccc3=C2N1" + }, + { + "base_idx": 78719, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 12, + 13, + 14, + 15, + 16, + 32, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "O=C(NC1CC1)C1=C[C@H](C2CCCCC2)C[C@H](OCCCCO)O1", + "scaffold_smiles": "O=C(NC1CC1)C1=CC(C2CCCCC2)CCO1" + }, + { + "base_idx": 47281, + "atom_indices": [ + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 15, + 17, + 20, + 22, + 24, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 44, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "O=C(O)CCN1C(=O)[C@@H]2[C@@H]3C[C@@H]([C@H]4Sc5c(sc(=O)n5-c5ccccc5)[C@H](c5ccccc5)[C@@H]34)[C@@H]2C1=O", + "scaffold_smiles": "O=C1NC(=O)C2C3CC(C4Sc5c(sc(=O)n5-c5ccccc5)C(c5ccccc5)C34)C12" + }, + { + "base_idx": 664030, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 19, + 28, + 29 + ], + "molecule_smiles": "CC(C)(C)c1cc2ncnc(N3CCCCC3)c2s1", + "scaffold_smiles": "c1nc(N2CCCCC2)c2sccc2n1" + }, + { + "base_idx": 395611, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 34, + 37, + 40 + ], + "molecule_smiles": "COc1cc2c(cc1OC)N1C(=O)C[C@@H](OCC(=O)O)[C@@H]3[C@H]1[C@H]2[C@@]12CCN1C(=O)C(=O)[C@@H]3C2", + "scaffold_smiles": "O=C1C(=O)N2CCC23CC1C1CCC(=O)N2c4ccccc4C3C12" + }, + { + "base_idx": 662908, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 26, + 27, + 28, + 29, + 30, + 31, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "CC(C)(C)n1nnnc1[C@@H](c1ccc(C(F)(F)F)cc1)N(Cc1ccc(F)cc1)Cc1ccco1", + "scaffold_smiles": "c1ccc(CN(Cc2ccco2)C(c2ccccc2)c2nnn[nH]2)cc1" + }, + { + "base_idx": 282101, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 18, + 24, + 25, + 35, + 36, + 45, + 46, + 51, + 52, + 53 + ], + "molecule_smiles": "Cc1c(C)c2c(OCC(=O)N3CCC(C(=O)O)CC3)cc3c(c2oc1=O)CCC(C)(C)O3", + "scaffold_smiles": "O=C(COc1cc2c(c3oc(=O)ccc13)CCCO2)N1CCCCC1" + }, + { + "base_idx": 31618, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 34, + 35, + 37, + 38, + 39 + ], + "molecule_smiles": "O=C1/C(=C\\c2ccccc2)[C@H]2OCC=C3CN4CC[C@@]56c7ccccc7N1[C@@H]5[C@@H]2[C@@H]3C[C@@H]46", + "scaffold_smiles": "O=C1C(=Cc2ccccc2)C2OCC=C3CN4CCC56c7ccccc7N1C5C2C3CC46" + }, + { + "base_idx": 174032, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12 + ], + "molecule_smiles": "Cc1nn(C)c(C)c1/C=C(/C(=O)C(C)(C)C)n1cncn1", + "scaffold_smiles": "C(=Cn1cncn1)c1cn[nH]c1" + }, + { + "base_idx": 530269, + "atom_indices": [ + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 32, + 35, + 43, + 49, + 51 + ], + "molecule_smiles": "CCOC(=O)N1CCN(C(=O)Nc2sc3nc4n(c(=O)c3c2C)CC[C@@H](C)CC4)CC1", + "scaffold_smiles": "O=C(Nc1cc2c(=O)n3c(nc2s1)CCCCC3)N1CCNCC1" + }, + { + "base_idx": 30032, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 9 + ], + "molecule_smiles": "O=C1CC(C(F)(F)F)=NN1", + "scaffold_smiles": "O=C1CC=NN1" + }, + { + "base_idx": 154776, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 13, + 14, + 16, + 22, + 23, + 24, + 26, + 27, + 28, + 37, + 38, + 39, + 40, + 42, + 43, + 44, + 45, + 53, + 54, + 55, + 57, + 58, + 59 + ], + "molecule_smiles": "Cn1cc(P(=S)(N2CCOCC2)N2CCOCC2)cc1P(=O)(N1CCOCC1)N1CCOCC1", + "scaffold_smiles": "O=P(c1cc(P(=S)(N2CCOCC2)N2CCOCC2)c[nH]1)(N1CCOCC1)N1CCOCC1" + }, + { + "base_idx": 5234, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 16, + 19, + 20, + 21, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "S=C1NC2=C(CCCC2=C2SCCS2)[C@@H](c2ccccc2)N1", + "scaffold_smiles": "S=C1NC2=C(CCCC2=C2SCCS2)C(c2ccccc2)N1" + }, + { + "base_idx": 159966, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15 + ], + "molecule_smiles": "Cn1c([O-])c2c(nc3[s+]ccn32)n(C)c1=O", + "scaffold_smiles": "O=c1[nH]cc2c(nc3[s+]ccn32)[nH]1" + }, + { + "base_idx": 565994, + "atom_indices": [ + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 27 + ], + "molecule_smiles": "CCN(CC)C(=O)C(=O)c1ccn(C2=C(C)C=[S+]/C2=C(/[O-])OC)c1", + "scaffold_smiles": "C=C1[S+]=CC=C1n1cccc1" + }, + { + "base_idx": 584961, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "CCCCN1C=CN2c3nc4ccccc4nc3N(S(=O)(=O)c3ccc(Br)cc3)[C@@H]12", + "scaffold_smiles": "O=S(=O)(c1ccccc1)N1c2nc3ccccc3nc2N2C=CNC21" + }, + { + "base_idx": 294538, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "COc1cnc(-c2ccccn2)nc1Oc1ccc(Cl)c2ccccc12", + "scaffold_smiles": "c1ccc(-c2nccc(Oc3cccc4ccccc34)n2)nc1" + }, + { + "base_idx": 274846, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 27, + 29, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "[H]/N=c1\\cc(N)nc2n1[C@](O)(c1cc(C)n(CCc3ccc(F)cc3)c1C)CS2", + "scaffold_smiles": "N=c1ccnc2n1C(c1ccn(CCc3ccccc3)c1)CS2" + }, + { + "base_idx": 265649, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "Cc1cc(C)n(C(=O)CCCCCCCC(=O)n2nc(C)cc2C)n1", + "scaffold_smiles": "O=C(CCCCCCCC(=O)n1cccn1)n1cccn1" + }, + { + "base_idx": 641429, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 32, + 33, + 36, + 46, + 47, + 48, + 49, + 50, + 51, + 54, + 55, + 56, + 59 + ], + "molecule_smiles": "C[C@@H](NC(=O)COC(=O)c1ccc2c(c1)S(=O)(=O)N=C1CCCCCN12)[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2", + "scaffold_smiles": "O=C(COC(=O)c1ccc2c(c1)S(=O)(=O)N=C1CCCCCN12)NCC12CC3CC(CC(C3)C1)C2" + }, + { + "base_idx": 535186, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "CCOC(=O)CSc1snnc1C", + "scaffold_smiles": "c1csnn1" + }, + { + "base_idx": 437540, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "COC(=O)CSc1nsc(SCC(=O)OC)n1", + "scaffold_smiles": "c1ncsn1" + }, + { + "base_idx": 237057, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 29, + 30 + ], + "molecule_smiles": "Cc1ccc(CNC(=O)[C@H]2C[C@H]3Cn4c(nc5cc(Cl)c(Cl)cc54)[C@H]3N2C)o1", + "scaffold_smiles": "O=C(NCc1ccco1)C1CC2Cn3c(nc4ccccc43)C2N1" + }, + { + "base_idx": 81120, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 32, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "O=C(N/N=C1\\C(=O)N([C@@H]2OC(=O)c3ccccc32)c2ccccc21)c1ccc(O)cc1", + "scaffold_smiles": "O=C(NN=C1C(=O)N(C2OC(=O)c3ccccc32)c2ccccc21)c1ccccc1" + }, + { + "base_idx": 615937, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 17, + 20, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "CC/C(Nc1ccc2c(c1)CCC2)=C1\\C(=O)NC(=O)N(C2CCCCC2)C1=O", + "scaffold_smiles": "O=C1NC(=O)N(C2CCCCC2)C(=O)C1=CNc1ccc2c(c1)CCC2" + }, + { + "base_idx": 12285, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 21, + 22, + 23 + ], + "molecule_smiles": "O=c1oc2c3c4c(cc2s1)CCCN4CCC3", + "scaffold_smiles": "O=c1oc2c3c4c(cc2s1)CCCN4CCC3" + }, + { + "base_idx": 142014, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 30, + 42, + 43 + ], + "molecule_smiles": "NC(=O)CCN1CCN(CCO[C@@H]2Cc3cc(Cl)ccc3Sc3ccccc32)CC1", + "scaffold_smiles": "c1ccc2c(c1)CC(OCCN1CCNCC1)c1ccccc1S2" + }, + { + "base_idx": 46588, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 28, + 29, + 30 + ], + "molecule_smiles": "O=C(O)CCc1nn2cnc3[s+]c4c(c-3c2nc1[O-])CCCC4", + "scaffold_smiles": "c1cnn2cnc3[s+]c4c(c-3c2n1)CCCC4" + }, + { + "base_idx": 692267, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 22, + 24, + 25, + 26, + 28, + 30 + ], + "molecule_smiles": "[H]/N=c1\\c2c(nc(SCC=C)n1CC=C)S[C@H]1CCC[C@@H]21", + "scaffold_smiles": "N=c1[nH]cnc2c1C1CCCC1S2" + }, + { + "base_idx": 83544, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 24, + 25, + 26, + 27, + 28, + 29, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 47, + 48, + 49 + ], + "molecule_smiles": "O=C(N/N=C/CCc1ccccc1)c1ccc(CN2c3cccc4cccc(c34)S2(=O)=O)cc1", + "scaffold_smiles": "O=C(NN=CCCc1ccccc1)c1ccc(CN2c3cccc4cccc(c34)S2(=O)=O)cc1" + }, + { + "base_idx": 31446, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 22, + 23, + 25 + ], + "molecule_smiles": "O=C1/C(=C/c2ccccn2)SC(=S)N1N1CCOCC1", + "scaffold_smiles": "O=C1C(=Cc2ccccn2)SC(=S)N1N1CCOCC1" + }, + { + "base_idx": 631915, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 31, + 33, + 34, + 36 + ], + "molecule_smiles": "CC1(C)Cc2cccc(OCC(=O)Nc3cc(C(F)(F)F)ccc3N3CCOCC3)c2O1", + "scaffold_smiles": "O=C(COc1cccc2c1OCC2)Nc1ccccc1N1CCOCC1" + }, + { + "base_idx": 651615, + "atom_indices": [ + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 43, + 44, + 45, + 46, + 47, + 48, + 49 + ], + "molecule_smiles": "CC(C)N(CCCNC(=O)Cn1c(=O)c2cccn2c2ccc(F)cc21)Cc1ccccc1", + "scaffold_smiles": "O=C(Cn1c(=O)c2cccn2c2ccccc21)NCCCNCc1ccccc1" + }, + { + "base_idx": 8018, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 27, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52 + ], + "molecule_smiles": "Oc1cc(O)c2c(c1)O[C@@H](c1ccc(O)c(O)c1)[C@H](O[C@@]1(c3ccc(O)c(O)c3)Oc3cc(O)cc(O)c3[C@H](O)[C@@H]1O)C2", + "scaffold_smiles": "c1ccc(C2Oc3ccccc3CC2OC2(c3ccccc3)CCc3ccccc3O2)cc1" + }, + { + "base_idx": 443168, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "COC(=O)/C(=C1/Oc2ccc(C)cc2S1)C(F)(F)F", + "scaffold_smiles": "C=C1Oc2ccccc2S1" + }, + { + "base_idx": 29455, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 15, + 22, + 24 + ], + "molecule_smiles": "O=C1CC[C@@H](C(=O)N2CSC[C@@H]2C(=O)O)N1", + "scaffold_smiles": "O=C1CCC(C(=O)N2CCSC2)N1" + }, + { + "base_idx": 457524, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 30, + 31, + 36, + 39, + 40, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "CN1CCCS(=O)(=O)O[C@@H]2C=C(/C=C\\c3ccc1cc3)N(C)c1ccccc12", + "scaffold_smiles": "O=S1(=O)CCCNc2ccc(cc2)C=CC2=CC(O1)c1ccccc1N2" + }, + { + "base_idx": 649812, + "atom_indices": [ + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "CC(C)N[C@H]1CCn2c(O)nc3cccc(c32)[C@@H]1O", + "scaffold_smiles": "c1cc2c3c(c1)ncn3CCCC2" + }, + { + "base_idx": 157240, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "Cn1c(NC(=O)C2c3ccccc3Oc3ccccc32)cc(=O)n(C)c1=O", + "scaffold_smiles": "O=C(Nc1cc(=O)[nH]c(=O)[nH]1)C1c2ccccc2Oc2ccccc21" + }, + { + "base_idx": 161802, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 39, + 40 + ], + "molecule_smiles": "Cn1c(=O)c2c(nc(N/N=C/c3ccncc3)n2Cc2ccccc2)n(C)c1=O", + "scaffold_smiles": "O=c1[nH]c(=O)c2c(nc(NN=Cc3ccncc3)n2Cc2ccccc2)[nH]1" + }, + { + "base_idx": 445129, + "atom_indices": [ + 3, + 4, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25, + 26, + 27, + 29, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 45 + ], + "molecule_smiles": "CN[C@H]1C[C@H]2O[C@@](C)([C@H]1OC)n1c3ccccc3c3c4c(c5c6ccccc6n2c5c31)C(=O)NC4", + "scaffold_smiles": "O=C1NCc2c1c1c3ccccc3n3c1c1c2c2ccccc2n1C1CCCC3O1" + }, + { + "base_idx": 452711, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 16, + 17, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "CN1C[C@H](c2ccccc2)[C@]2(COc3ccccc3C2=O)[C@]12C(=O)Nc1ccccc12", + "scaffold_smiles": "O=C1Nc2ccccc2C12NCC(c1ccccc1)C21COc2ccccc2C1=O" + }, + { + "base_idx": 466537, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "CCn1ncc(S(=O)(=O)Nc2cccnc2)c1C", + "scaffold_smiles": "O=S(=O)(Nc1cccnc1)c1cn[nH]c1" + }, + { + "base_idx": 653457, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CC(C)CSc1n[nH]c(=S)s1", + "scaffold_smiles": "S=c1[nH]ncs1" + }, + { + "base_idx": 10973, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 11, + 12, + 14, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "O[C@]1(c2ccccc2Cl)NN=C2CCCCCN21", + "scaffold_smiles": "c1ccc(C2NN=C3CCCCCN32)cc1" + }, + { + "base_idx": 439109, + "atom_indices": [ + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 55, + 56, + 57, + 58, + 59, + 60, + 61 + ], + "molecule_smiles": "COC(=O)CN1C(=O)[C@H]2[C@@H](/C=C/c3ccccc3)N3C(=O)CN(CC4CC4)C(=O)[C@@]3(Cc3ccccc3)[C@H]2C1=O", + "scaffold_smiles": "O=C1NC(=O)C2C1C(C=Cc1ccccc1)N1C(=O)CN(CC3CC3)C(=O)C21Cc1ccccc1" + }, + { + "base_idx": 266814, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 22, + 24, + 27, + 29, + 31, + 34, + 35, + 36, + 37, + 38, + 39, + 48 + ], + "molecule_smiles": "Cc1cc(C)n(-c2nc(NC[C@@H]3CCCO3)c3c4c(sc3n2)COC(C)(C)C4)n1", + "scaffold_smiles": "c1cnn(-c2nc(NCC3CCCO3)c3c4c(sc3n2)COCC4)c1" + }, + { + "base_idx": 430254, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "COC(=O)c1ccc2[nH]c3cccc(/C(=C/c4ccccc4)c4ccccc4)c3c2c1", + "scaffold_smiles": "C(=C(c1ccccc1)c1cccc2[nH]c3ccccc3c12)c1ccccc1" + }, + { + "base_idx": 262967, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "Cc1cc(C2=C[C@@](C)(c3cc(C)oc3C)Nc3nc(C(F)(F)F)nn32)c(C)o1", + "scaffold_smiles": "C1=C(c2ccoc2)n2ncnc2NC1c1ccoc1" + }, + { + "base_idx": 625052, + "atom_indices": [ + 4, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 17, + 22, + 23, + 24, + 31, + 34, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "C[C@@H]1C[C@@H]2OC(=O)[C@@]3(C)CCC[C@](C)([C@@H]23)[C@@]1(O)CCc1ccoc1", + "scaffold_smiles": "O=C1OC2CCC(CCc3ccoc3)C3CCCC1C23" + }, + { + "base_idx": 21909, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 28, + 29, + 32, + 35, + 38 + ], + "molecule_smiles": "O=S(=O)(Nc1ccc(Cc2ccncc2)cc1)N1CCCC1", + "scaffold_smiles": "O=S(=O)(Nc1ccc(Cc2ccncc2)cc1)N1CCCC1" + }, + { + "base_idx": 553384, + "atom_indices": [ + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 20, + 21, + 22, + 23, + 24, + 36, + 38 + ], + "molecule_smiles": "CCN1CCN(C2=CC3=CC(=C(C#N)C#N)CC[C@H]3CC2)CC1", + "scaffold_smiles": "C=C1C=C2C=C(N3CCNCC3)CCC2CC1" + }, + { + "base_idx": 522709, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "CCOC(=O)c1cc2c(ccc3nc(C)c(C)c32)[nH]c1C", + "scaffold_smiles": "c1c[nH]c2ccc3nccc3c2c1" + }, + { + "base_idx": 463157, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "CN(C)/C=C/C(=O)c1nnn(C2CCCCC2)c1NC1CCCCC1", + "scaffold_smiles": "c1nnn(C2CCCCC2)c1NC1CCCCC1" + } + ] +} diff --git a/geom/scaffold_murcko_substituent_lora_r16/panels/val_murcko_512_seed42.json b/geom/scaffold_murcko_substituent_lora_r16/panels/val_murcko_512_seed42.json new file mode 100644 index 0000000000000000000000000000000000000000..91b911d98a0026a38c2a60ebad7bd192aa07c665 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/panels/val_murcko_512_seed42.json @@ -0,0 +1,13606 @@ +{ + "metadata": { + "version": 1, + "split": "val", + "dataset": "/cluster/project/krause/frankem/morph/data/geom/processed/val_data.lmdb", + "records": 703830, + "storage": [ + 5595873280, + 1779830840276635000 + ], + "kind": "murcko", + "n_scaffolds": 512, + "seed": 42 + }, + "candidate_molecules": 27728, + "candidate_scaffolds": 18047, + "entries": [ + { + "base_idx": 74918, + "atom_indices": [ + 0, + 1, + 2, + 4, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 32, + 33, + 34, + 36, + 38, + 39, + 40, + 42, + 51, + 52, + 53, + 54, + 55 + ], + "molecule_smiles": "O=C(NCC(=O)N(Cc1ccco1)[C@H](C(=O)NC1CCCC1)c1cccnc1)c1cccs1", + "scaffold_smiles": "O=C(NCC(=O)N(Cc1ccco1)C(C(=O)NC1CCCC1)c1cccnc1)c1cccs1" + }, + { + "base_idx": 663928, + "atom_indices": [ + 5, + 6, + 7, + 8, + 16, + 18 + ], + "molecule_smiles": "C[C@@H](C(N)=O)N1CCN(S(C)(=O)=O)CC1", + "scaffold_smiles": "C1CNCCN1" + }, + { + "base_idx": 671091, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9 + ], + "molecule_smiles": "CC(=O)Nc1cccc(S(C)(=O)=O)c1", + "scaffold_smiles": "c1ccccc1" + }, + { + "base_idx": 147480, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 13, + 14, + 15, + 22, + 23, + 25, + 27, + 28, + 29, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "FC(F)(F)/C(=N/P(=S)(N1CCOCC1)N1CCOCC1)N1CCOCC1", + "scaffold_smiles": "S=P(N=CN1CCOCC1)(N1CCOCC1)N1CCOCC1" + }, + { + "base_idx": 24997, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 18 + ], + "molecule_smiles": "O=c1[nH]c([O-])c(/C=C2/C=C[S+]=C2)c(=O)[nH]1", + "scaffold_smiles": "O=c1[nH]cc(C=C2C=C[S+]=C2)c(=O)[nH]1" + }, + { + "base_idx": 77078, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 19, + 20, + 21 + ], + "molecule_smiles": "O=C(N[C@@H]1C/C(=N/O)c2cc3c(cc21)OCO3)C(F)(F)F", + "scaffold_smiles": "N=C1CCc2cc3c(cc21)OCO3" + }, + { + "base_idx": 694608, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 25, + 27 + ], + "molecule_smiles": "C1CCN(c2c3c(nc4nnnn24)CCCC3)CC1", + "scaffold_smiles": "C1CCN(c2c3c(nc4nnnn24)CCCC3)CC1" + }, + { + "base_idx": 623856, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 21, + 23 + ], + "molecule_smiles": "C[C@@H]1[C@H](OC(=O)CCC(=O)NC(C)(C)CO)O[C@H]2O[C@]3(C)CC[C@H]4[C@H](C)CC[C@@H]1[C@]24OO3", + "scaffold_smiles": "C1CC2CCOC3OC4CCC(C1)C23OO4" + }, + { + "base_idx": 1151, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "c1ccc2c(Cn3c[s+]c4ccccc43)cccc2c1", + "scaffold_smiles": "c1ccc2c(Cn3c[s+]c4ccccc43)cccc2c1" + }, + { + "base_idx": 610197, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13 + ], + "molecule_smiles": "CCC(=O)Nc1nc(=S)ss1", + "scaffold_smiles": "S=c1ncss1" + }, + { + "base_idx": 541683, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)OC(N)=C(C#N)[C@H]1c1ccoc1", + "scaffold_smiles": "C1=CC(c2ccoc2)C=CO1" + }, + { + "base_idx": 633007, + "atom_indices": [ + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 17, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "[H]/N=c1\\[nH]nc2n1[C@](O)([C@]13C[C@H]4C[C@H](C[C@H](C4)C1)C3)[C@@H](C)S2", + "scaffold_smiles": "N=c1[nH]nc2n1C(C13CC4CC(CC(C4)C1)C3)CS2" + }, + { + "base_idx": 278808, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 27, + 28, + 29 + ], + "molecule_smiles": "C[C@]12CC[C@H]3[C@@H](CCC4=CC(=O)CC[C@@]43C=O)[C@H]1CCC2=O", + "scaffold_smiles": "O=C1C=C2CCC3C4CCC(=O)C4CCC3C2CC1" + }, + { + "base_idx": 389726, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "COc1cc2c(cc1F)C(c1ccccc1Cl)=NC1=C(C)NNC1=N2", + "scaffold_smiles": "C1=C2N=C(c3ccccc3)c3ccccc3N=C2NN1" + }, + { + "base_idx": 624790, + "atom_indices": [ + 1, + 2, + 3, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "C/C1=C/CC/C(C)=C\\CC(C)(C)/C=C/C1", + "scaffold_smiles": "C1=CCCC=CCCC=CC1" + }, + { + "base_idx": 587448, + "atom_indices": [ + 9, + 10, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCCCNS(=O)(=O)[C@@H]1CCS(=O)(=O)C1", + "scaffold_smiles": "O=S1(=O)CCCC1" + }, + { + "base_idx": 280279, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "CSc1nnnn1CCn1nnnc1SC", + "scaffold_smiles": "c1nnnn1CCn1cnnn1" + }, + { + "base_idx": 80291, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 7, + 9, + 12, + 14, + 17, + 20, + 25, + 26, + 27 + ], + "molecule_smiles": "O=C(N/N=C/[C@@H]1C[C@H]2CC[C@@H]1C2)C1CC1", + "scaffold_smiles": "O=C(NN=CC1CC2CCC1C2)C1CC1" + }, + { + "base_idx": 128048, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "Nc1nc(O)c2nc(Br)n([C@@H]3O[C@@H]4CO[P@](=O)(O)O[C@H]4[C@H]3O)c2n1", + "scaffold_smiles": "O=[PH]1OCC2OC(n3cnc4cncnc43)CC2O1" + }, + { + "base_idx": 695840, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 23 + ], + "molecule_smiles": "C=CCNC(=S)NC1=[S+]C2=C(CCCC2)/C1=C(/N)[O-]", + "scaffold_smiles": "C=C1C=[S+]C2=C1CCCC2" + }, + { + "base_idx": 335689, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 41, + 42, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "COc1ccc(NS(=O)(=O)c2cc(N/C(=N\\S(=O)(=O)c3ccccc3)c3ccc(F)cc3)ccc2Cl)cc1", + "scaffold_smiles": "O=S(=O)(N=C(Nc1cccc(S(=O)(=O)Nc2ccccc2)c1)c1ccccc1)c1ccccc1" + }, + { + "base_idx": 138780, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "NC(=O)[C@@H]1c2ccccc2CSc2ccccc21", + "scaffold_smiles": "c1ccc2c(c1)CSc1ccccc1C2" + }, + { + "base_idx": 552810, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 20, + 27, + 28 + ], + "molecule_smiles": "CCN1C(=O)/C(=C/C=C2/CCCN2C)OC1=S", + "scaffold_smiles": "O=C1NC(=S)OC1=CC=C1CCCN1" + }, + { + "base_idx": 700987, + "atom_indices": [ + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "C=CCCCCCCCCc1nc(SCC(=O)O)n[nH]1", + "scaffold_smiles": "c1nc[nH]n1" + }, + { + "base_idx": 520486, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "CCOC(=O)c1c(C)n(C)c2ccc(OC(=O)c3ccc(N4C(=O)CCC4=O)cc3)cc12", + "scaffold_smiles": "O=C(Oc1ccc2[nH]ccc2c1)c1ccc(N2C(=O)CCC2=O)cc1" + }, + { + "base_idx": 543256, + "atom_indices": [ + 7, + 8, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "CCOC(=O)[C@H]1[S+]=C2C=C(C)N=C(N)N2[C@@]1(C)[O-]", + "scaffold_smiles": "C1=CC2=[S+]CCN2C=N1" + }, + { + "base_idx": 635, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "c1ccc2oc(SCc3nc4ccccc4s3)nc2c1", + "scaffold_smiles": "c1ccc2oc(SCc3nc4ccccc4s3)nc2c1" + }, + { + "base_idx": 261107, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 24, + 25, + 27 + ], + "molecule_smiles": "CC1=CN2c3[nH]c([O-])[s+]c3[C@H]3SC(N)=NN3[C@H]2C(C)=C1", + "scaffold_smiles": "C1=CC2N(C=C1)c1[nH]c[s+]c1C1SC=NN12" + }, + { + "base_idx": 702013, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 11, + 18, + 19, + 20, + 21, + 24, + 27 + ], + "molecule_smiles": "C(#CCN1CCCC1)CN1CCCC1", + "scaffold_smiles": "C(#CCN1CCCC1)CN1CCCC1" + }, + { + "base_idx": 28715, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 33, + 34, + 35, + 36, + 37, + 38 + ], + "molecule_smiles": "O=C1[C@@H]2[C@H]3c4ccccc4[C@H](c4ccccc43)[C@@H]2C(=O)N1c1ccccn1", + "scaffold_smiles": "O=C1C2C3c4ccccc4C(c4ccccc43)C2C(=O)N1c1ccccn1" + }, + { + "base_idx": 554651, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "CCN(/N=N/c1nonc1N)c1nonc1N", + "scaffold_smiles": "c1nonc1N=NNc1cnon1" + }, + { + "base_idx": 627207, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 22, + 23, + 24, + 25, + 28, + 29 + ], + "molecule_smiles": "CC1(C)O[C@@H]2[C@@H](CO[C@@]3(COS(N)(=O)=O)OC(C)(C)O[C@@H]23)O1", + "scaffold_smiles": "C1OC2COC3OCOC3C2O1" + }, + { + "base_idx": 684004, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 33, + 37 + ], + "molecule_smiles": "C=CCN1C(=O)C2=CC(CC)=[S+]C2=N/C1=[S+]\\CC1=Nc2c/c(=C(/[O-])OC)ccc2=C([O-])N1", + "scaffold_smiles": "C=c1ccc2c(c1)N=C(C[S+]=C1N=C3[S+]=CC=C3C(=O)N1)NC=2" + }, + { + "base_idx": 252685, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "Cc1cc(Nc2ncnc3ccc(-c4ccc(CNCCS(C)(=O)=O)o4)cc23)ccc1Oc1ccn2ncnc2c1", + "scaffold_smiles": "c1coc(-c2ccc3ncnc(Nc4ccc(Oc5ccn6ncnc6c5)cc4)c3c2)c1" + }, + { + "base_idx": 258350, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 31, + 32, + 33, + 34, + 35, + 36, + 45 + ], + "molecule_smiles": "Cc1cc(C)n(-c2nc3sc4c(c3c3nnc(SC(C)C)n23)CC(C)(C)OC4)n1", + "scaffold_smiles": "c1cnn(-c2nc3sc4c(c3c3nncn23)CCOC4)c1" + }, + { + "base_idx": 440637, + "atom_indices": [ + 7, + 8, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 36, + 39 + ], + "molecule_smiles": "COC(=O)C1=C(C(=O)OC)[C@@H]2Nc3ccccc3[C@@]23C[C@@H](C(=O)OC)N=C3N1", + "scaffold_smiles": "C1=CC2Nc3ccccc3C23CCN=C3N1" + }, + { + "base_idx": 577499, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CCCNC(=O)c1oc2ccc3c(c2c1C)C=CC(C)(C)O3", + "scaffold_smiles": "C1=Cc2c(ccc3occc23)OC1" + }, + { + "base_idx": 460145, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 23 + ], + "molecule_smiles": "CN(C)C(=O)CCSc1nnc(-c2ccncc2)o1", + "scaffold_smiles": "c1cc(-c2nnco2)ccn1" + }, + { + "base_idx": 48658, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 26, + 27, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "O=C(O)[C@@H]1C[C@H]([C@H]2CCCCN2)CN2CCCC[C@@H]12", + "scaffold_smiles": "C1CCC(C2CCC3CCCCN3C2)NC1" + }, + { + "base_idx": 6869, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "Oc1ccc(/C=N/N(Cc2ccccc2)c2ccccc2)c(O)c1O", + "scaffold_smiles": "C(=NN(Cc1ccccc1)c1ccccc1)c1ccccc1" + }, + { + "base_idx": 569851, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 27, + 31, + 32, + 33 + ], + "molecule_smiles": "CCCc1nc(SCC(=O)Nc2nnc(CC)s2)c2c(C)c(C)sc2n1", + "scaffold_smiles": "O=C(CSc1ncnc2sccc12)Nc1nncs1" + }, + { + "base_idx": 654778, + "atom_indices": [ + 10, + 11, + 12, + 13, + 24, + 25, + 26, + 29, + 30, + 31, + 34 + ], + "molecule_smiles": "CC(C)[C@]12CN3C[C@](C(C)C)(CN(C1)C3(C)C)C2=O", + "scaffold_smiles": "O=C1C2CN3CC1CN(C2)C3" + }, + { + "base_idx": 471954, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 23 + ], + "molecule_smiles": "CCc1nc2n[nH]c(N)c2c2c1CSC(C)(C)C2", + "scaffold_smiles": "c1nc2n[nH]cc2c2c1CSCC2" + }, + { + "base_idx": 694342, + "atom_indices": [ + 0, + 1, + 4, + 5, + 6, + 7, + 8, + 9, + 19, + 21 + ], + "molecule_smiles": "C1C[C@H](CNC2CC2)CO1", + "scaffold_smiles": "C1CC(CNC2CC2)CO1" + }, + { + "base_idx": 84054, + "atom_indices": [ + 0, + 1, + 2, + 4, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 24, + 25, + 26, + 27, + 28, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 50, + 53, + 56 + ], + "molecule_smiles": "O=C(Cc1coc2cc3c(cc12)CCC3)NCC1(N2CCCCC2)CCCCC1", + "scaffold_smiles": "O=C(Cc1coc2cc3c(cc12)CCC3)NCC1(N2CCCCC2)CCCCC1" + }, + { + "base_idx": 669760, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 22, + 23 + ], + "molecule_smiles": "CC(=O)Oc1ccc2c(c1)=C([O-])O/C(=C1/C=CC=[S+]1)N=2", + "scaffold_smiles": "C1=CC(=C2N=c3ccccc3=CO2)[S+]=C1" + }, + { + "base_idx": 678133, + "atom_indices": [ + 3, + 4, + 5, + 9, + 10, + 11, + 12, + 14, + 17 + ], + "molecule_smiles": "CC(=O)N1N=C(N)SC12CCCC2", + "scaffold_smiles": "C1=NNC2(CCCC2)S1" + }, + { + "base_idx": 624897, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 17, + 18, + 19, + 20, + 21, + 27, + 29, + 38, + 39, + 40 + ], + "molecule_smiles": "CC1=CC(C)(C)N2C(=O)[C@]3(C(C#N)=C(N)OC4=C3C(=O)CC(C)(C)C4)c3cccc1c32", + "scaffold_smiles": "O=C1CCCC2=C1C1(C=CO2)C(=O)N2CC=Cc3cccc1c32" + }, + { + "base_idx": 172034, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11 + ], + "molecule_smiles": "CC1=[S+]C2=CC(=P([O-])(O)O)C=CC2=N1", + "scaffold_smiles": "P=C1C=CC2=NC=[S+]C2=C1" + }, + { + "base_idx": 441482, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 18, + 19, + 20 + ], + "molecule_smiles": "CNC(=O)c1cccn1-c1ccsc1C(=O)OC", + "scaffold_smiles": "c1ccn(-c2ccsc2)c1" + }, + { + "base_idx": 30, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "c1nc2ccc(Cc3ccc4nc[nH]c4c3)cc2[nH]1", + "scaffold_smiles": "c1nc2ccc(Cc3ccc4nc[nH]c4c3)cc2[nH]1" + }, + { + "base_idx": 622238, + "atom_indices": [ + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 17, + 26, + 27, + 28 + ], + "molecule_smiles": "C[C@@H]1CC[C@@H]2[C@@H](C1)c1sc(=O)[nH]c1SC2(C)C", + "scaffold_smiles": "O=c1[nH]c2c(s1)C1CCCCC1CS2" + }, + { + "base_idx": 244400, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 27, + 30, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "Cc1ccc(-n2c([O-])c3c4c([s+]c-3n(Cc3cc(=O)n5ccccc5n3)c2=O)CCC4)cc1C", + "scaffold_smiles": "O=c1n(-c2ccccc2)cc2c3c([s+]c-2n1Cc1cc(=O)n2ccccc2n1)CCC3" + }, + { + "base_idx": 676962, + "atom_indices": [ + 5, + 6, + 8, + 9, + 11, + 12, + 16, + 19 + ], + "molecule_smiles": "CC(=O)N[C@@H]1C[C@H]2CC[C@@H](C1)N2", + "scaffold_smiles": "C1CC2CCC(C1)N2" + }, + { + "base_idx": 266995, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "Cc1cc(=O)oc2c1c(C)nn2-c1nn[nH]n1", + "scaffold_smiles": "O=c1ccc2cnn(-c3nn[nH]n3)c2o1" + }, + { + "base_idx": 119115, + "atom_indices": [ + 0, + 1, + 2, + 4, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 25, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "O=C(CCCCCn1c(=O)[nH]c2ccccc2c1=O)NCCc1c[nH]c2ccccc12", + "scaffold_smiles": "O=C(CCCCCn1c(=O)[nH]c2ccccc2c1=O)NCCc1c[nH]c2ccccc12" + }, + { + "base_idx": 693060, + "atom_indices": [ + 0, + 1, + 2, + 5, + 6, + 7, + 8, + 9, + 18, + 23 + ], + "molecule_smiles": "C=C1C[C@H]2C=C(NC(C)=O)C[C@@H](C1)C2", + "scaffold_smiles": "C=C1CC2C=CCC(C1)C2" + }, + { + "base_idx": 527155, + "atom_indices": [ + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 33, + 38, + 44, + 46 + ], + "molecule_smiles": "CCOC(=O)N1CCN(C(=O)C(=O)NC2=[S+]C3=C(CC(C)(C)OC3)/C2=C(\\N)[O-])CC1", + "scaffold_smiles": "C=C1C(NC(=O)C(=O)N2CCNCC2)=[S+]C2=C1CCOC2" + }, + { + "base_idx": 25178, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 23 + ], + "molecule_smiles": "O=C1CSC(=S)N1/N=C/c1ccc2c(c1)OCCO2", + "scaffold_smiles": "O=C1CSC(=S)N1N=Cc1ccc2c(c1)OCCO2" + }, + { + "base_idx": 19101, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 25, + 28, + 29 + ], + "molecule_smiles": "O=S1(=O)CCCN2C=CC(Oc3ccccc3)=C[C@@H]2O1", + "scaffold_smiles": "O=S1(=O)CCCN2C=CC(Oc3ccccc3)=CC2O1" + }, + { + "base_idx": 608288, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19 + ], + "molecule_smiles": "CCC(C)(C)n1nnnc1[C@H](C(C)C)N1CCSCC1", + "scaffold_smiles": "C1CN(Cc2nnn[nH]2)CCS1" + }, + { + "base_idx": 689470, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10 + ], + "molecule_smiles": "C=CCNC(=S)/N=c1\\sccn1C", + "scaffold_smiles": "N=c1[nH]ccs1" + }, + { + "base_idx": 625801, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 17, + 18, + 19, + 30, + 32, + 33 + ], + "molecule_smiles": "CC1=C(C(=O)O)N2C(=O)[C@@H](NC(=O)[C@@H](N)C3=CCC=CC3)[C@@H]2SC1", + "scaffold_smiles": "O=C(CC1=CCC=CC1)NC1C(=O)N2C=CCSC12" + }, + { + "base_idx": 164495, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "Cc1noc(C)c1C/[S+]=C1C=CC=CC\\1=C(/[O-])Nc1nccs1", + "scaffold_smiles": "C1=CC(=CNc2nccs2)C(=[S+]Cc2cnoc2)C=C1" + }, + { + "base_idx": 697376, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "C/C(=N\\Nc1cccc2cccnc12)c1ccc[nH]1", + "scaffold_smiles": "C(=NNc1cccc2cccnc12)c1ccc[nH]1" + }, + { + "base_idx": 670519, + "atom_indices": [ + 7, + 8, + 11, + 12, + 13, + 16, + 18, + 23, + 24, + 25, + 28, + 33, + 35, + 38, + 39, + 40 + ], + "molecule_smiles": "CC(=O)OC[C@]1(OC(C)=O)C[C@@]23CC[C@H]4[C@@](C)(CCC[C@@]4(C)C(=O)O)[C@@H]2CC[C@@H]1C3", + "scaffold_smiles": "C1CCC2C(C1)CCC13CCC(CCC21)C3" + }, + { + "base_idx": 230889, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 22 + ], + "molecule_smiles": "Cc1ccc(C=C2C(=O)c3ccccc3C2=O)o1", + "scaffold_smiles": "O=C1C(=Cc2ccco2)C(=O)c2ccccc21" + }, + { + "base_idx": 444728, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 23, + 24, + 25 + ], + "molecule_smiles": "CN1CCc2c(sc3c2C(=O)N[C@@H](c2cccs2)N3)C1", + "scaffold_smiles": "O=C1NC(c2cccs2)Nc2sc3c(c21)CCNC3" + }, + { + "base_idx": 553546, + "atom_indices": [ + 5, + 6, + 7, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCN1CCC[C@@H]1CNC(=O)CCc1nnc(CCC2CCCCC2)o1", + "scaffold_smiles": "O=C(CCc1nnc(CCC2CCCCC2)o1)NCC1CCCN1" + }, + { + "base_idx": 79333, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 28, + 30, + 31, + 33, + 42, + 43, + 49, + 50, + 53, + 54, + 55, + 57 + ], + "molecule_smiles": "O=C(N1CCOCC1)N1CCC(c2nc(-c3ccc(S(=O)(=O)N4CCOCC4)cc3)no2)CC1", + "scaffold_smiles": "O=C(N1CCOCC1)N1CCC(c2nc(-c3ccc(S(=O)(=O)N4CCOCC4)cc3)no2)CC1" + }, + { + "base_idx": 3140, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 15, + 16, + 21, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "c1ccc(C2(c3ccccc3)CC2=C2NCCN2)cc1", + "scaffold_smiles": "c1ccc(C2(c3ccccc3)CC2=C2NCCN2)cc1" + }, + { + "base_idx": 169483, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "Cc1nccn1C[C@H](O)COC1CCCCC1", + "scaffold_smiles": "c1cn(CCCOC2CCCCC2)cn1" + }, + { + "base_idx": 552877, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 21, + 22, + 23, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "CCN1C(=O)CN=C2NC=C(c3ccc(-c4nnc[nH]4)nc3C)N=C21", + "scaffold_smiles": "O=C1CN=C2NC=C(c3ccc(-c4nnc[nH]4)nc3)N=C2N1" + }, + { + "base_idx": 480646, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CCC1=C(C)[S+]=C(NC(=S)Nc2nc(C)cc(C)n2)/C1=C(/[O-])OC", + "scaffold_smiles": "C=C1C=C[S+]=C1NC(=S)Nc1ncccn1" + }, + { + "base_idx": 627911, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 48, + 49, + 50, + 53 + ], + "molecule_smiles": "CC1(C)CCC(C)(C)c2cc(C3(c4ccc5cc(C(=O)O)ccc5c4)SCCS3)ccc21", + "scaffold_smiles": "c1ccc2cc(C3(c4ccc5c(c4)CCCC5)SCCS3)ccc2c1" + }, + { + "base_idx": 279779, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "C[C@@H](c1ncncc1F)[C@](O)(Cn1cncn1)c1ccc(F)cc1F", + "scaffold_smiles": "c1ccc(C(Cc2ccncn2)Cn2cncn2)cc1" + }, + { + "base_idx": 536888, + "atom_indices": [ + 7, + 8, + 11, + 12, + 13, + 14, + 16, + 18, + 21, + 22, + 23, + 25, + 27, + 32, + 33, + 34 + ], + "molecule_smiles": "CCOC(=O)C1CCN(C(=S)N[C@H]2C[C@H]3C=C[C@@H]2C3)CC1", + "scaffold_smiles": "S=C(NC1CC2C=CC1C2)N1CCCCC1" + }, + { + "base_idx": 624500, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 22, + 23, + 24, + 35, + 36, + 38, + 39 + ], + "molecule_smiles": "[H]/N=c1/c2c(nc3cc(C)on13)O[C@]1(C)C(=C2)C=C(C)N1c1ccccc1", + "scaffold_smiles": "N=c1c2c(nc3ccon13)OC1C(=C2)C=CN1c1ccccc1" + }, + { + "base_idx": 157252, + "atom_indices": [ + 1, + 2, + 3, + 4, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 27, + 28, + 29 + ], + "molecule_smiles": "Clc1nc(Cl)nc(NCCC2=CCCCC2)n1", + "scaffold_smiles": "C1=C(CCNc2ncncn2)CCCC1" + }, + { + "base_idx": 438973, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "COC(=O)C1=C(C)NC(C)=C(C(=O)OC(C)C)[C@H]1c1cccc2nonc12", + "scaffold_smiles": "C1=CC(c2cccc3nonc23)C=CN1" + }, + { + "base_idx": 448265, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "CN1C(=O)C[C@H](/[S+]=C2C=CC=CC/2=C(\\[O-])O)C1=O", + "scaffold_smiles": "C=C1C=CC=CC1=[S+]C1CC(=O)NC1=O" + }, + { + "base_idx": 416938, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 19, + 20, + 21, + 22, + 24, + 26, + 27, + 29, + 37, + 38, + 39, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "COCCN1C=C2C(=O)[C@@](C)(OC(=O)C3CCCC3)C(=O)C=C2C=C1c1ccsc1", + "scaffold_smiles": "O=C(OC1C(=O)C=C2C=C(c3ccsc3)NC=C2C1=O)C1CCCC1" + }, + { + "base_idx": 444060, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "CN1Cc2cc(C(=O)c3cc(Cl)cc(CSc4ncccn4)c3O)cnc2NC1=O", + "scaffold_smiles": "O=C1NCc2cc(C(=O)c3cccc(CSc4ncccn4)c3)cnc2N1" + }, + { + "base_idx": 702168, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 17, + 18, + 34 + ], + "molecule_smiles": "C#CCNC(=O)C1=C[C@H](C2CC2)C[C@H](OCCCCO)O1", + "scaffold_smiles": "C1=CC(C2CC2)CCO1" + }, + { + "base_idx": 597871, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 28, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38 + ], + "molecule_smiles": "CCCC1=Nc2c/c(=C(/[O-])NCCCN3CCOCC3)ccc2=[S+]c2ccccc21", + "scaffold_smiles": "C1=Nc2cc(=CNCCCN3CCOCC3)ccc2=[S+]c2ccccc21" + }, + { + "base_idx": 46119, + "atom_indices": [ + 8, + 10, + 11, + 14, + 15 + ], + "molecule_smiles": "O=C(O)CCCC[C@H]1CCSS1", + "scaffold_smiles": "C1CSSC1" + }, + { + "base_idx": 464869, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 29, + 30, + 31, + 32, + 33, + 35, + 38, + 40 + ], + "molecule_smiles": "CCn1c(O)c(/C=N/N=C2CCCC2)c2ccc(C3=NNC(=O)CC3)cc21", + "scaffold_smiles": "O=C1CCC(c2ccc3c(C=NN=C4CCCC4)c[nH]c3c2)=NN1" + }, + { + "base_idx": 552110, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCN1C(=S)[S+]=C([O-])C1=C1C=C(C)N(CCO)C(C)=C1", + "scaffold_smiles": "S=C1NC(=C2C=CNC=C2)C=[S+]1" + }, + { + "base_idx": 552807, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 20, + 25 + ], + "molecule_smiles": "CCn1c([O-])c(/C=C\\C2=[S+]CCN2C)sc1=S", + "scaffold_smiles": "S=c1[nH]cc(C=CC2=[S+]CCN2)s1" + }, + { + "base_idx": 553204, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "CCn1c(/C=C2/C=Cc3cc(C)ccc3N2C)[s+]c2ccc3sccc3c21", + "scaffold_smiles": "C1=Cc2ccccc2NC1=Cc1[nH]c2c(ccc3sccc32)[s+]1" + }, + { + "base_idx": 629423, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 24, + 27, + 28, + 30, + 32, + 35, + 39 + ], + "molecule_smiles": "CC1(C)CC(=O)C2=C(C1)N(C[C@@H]1CCCO1)C(=O)[C@]2(O)C(F)(F)F", + "scaffold_smiles": "O=C1CCCC2=C1CC(=O)N2CC1CCCO1" + }, + { + "base_idx": 168614, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "Cc1nn(C)cc1CNC(=O)/C=C/c1cnn(C)c1", + "scaffold_smiles": "O=C(C=Cc1cn[nH]c1)NCc1cn[nH]c1" + }, + { + "base_idx": 659050, + "atom_indices": [ + 13, + 14, + 17, + 19, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 28, + 30, + 31, + 32, + 33, + 36 + ], + "molecule_smiles": "CC(C)(C)[C@]1(O)C[C@@H]2OC(=O)C[C@@]23C(=O)O[C@@H]2OC(=O)[C@H](O)[C@]213", + "scaffold_smiles": "O=C1CC23CCC4OC(=O)CC42C(=O)OC3O1" + }, + { + "base_idx": 690303, + "atom_indices": [ + 6, + 7, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 27 + ], + "molecule_smiles": "C=CCNC(=O)[C@@H]1C[C@@H]2Cn3c(nc4cc(C)c(C)cc43)[C@@H]2N1C", + "scaffold_smiles": "c1ccc2c(c1)nc1n2CC2CCNC12" + }, + { + "base_idx": 624723, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17 + ], + "molecule_smiles": "CC1=Nn2c(nnc2-c2cccs2)SC1", + "scaffold_smiles": "C1=Nn2c(nnc2-c2cccs2)SC1" + }, + { + "base_idx": 125979, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 13, + 16, + 21, + 23, + 24 + ], + "molecule_smiles": "O/N=C1\\CCCc2oc3c(c21)CCCC3", + "scaffold_smiles": "N=C1CCCc2oc3c(c21)CCCC3" + }, + { + "base_idx": 168733, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Cc1nn(C(=O)CCCC(=O)n2nc(C)c(Cl)c2C)c(C)c1Cl", + "scaffold_smiles": "O=C(CCCC(=O)n1cccn1)n1cccn1" + }, + { + "base_idx": 569473, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCCc1cc(C(=O)Nc2cc(C)on2)no1", + "scaffold_smiles": "O=C(Nc1ccon1)c1ccon1" + }, + { + "base_idx": 638001, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 22, + 23, + 28 + ], + "molecule_smiles": "C[C@@H](CO)NC(=O)[C@H]1C=C2c3cccc4[nH]cc(c34)C[C@H]2N(C)C1", + "scaffold_smiles": "C1=C2c3cccc4[nH]cc(c34)CC2NCC1" + }, + { + "base_idx": 542418, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 14 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)NC(=O)NC1", + "scaffold_smiles": "O=C1NC=CCN1" + }, + { + "base_idx": 921, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 23 + ], + "molecule_smiles": "c1ccc2c(c1)OC[C@H](CNC1=NCCCCC1)O2", + "scaffold_smiles": "c1ccc2c(c1)OCC(CNC1=NCCCCC1)O2" + }, + { + "base_idx": 614345, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "C[C@@H]1Cc2ccccc2N1c1ncnc2nc[nH]c12", + "scaffold_smiles": "c1ccc2c(c1)CCN2c1ncnc2nc[nH]c12" + }, + { + "base_idx": 679131, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 17, + 18, + 19, + 20, + 21, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CC(=O)N1CCc2cc(NN=C3C(=O)CCCC3=O)ccc21", + "scaffold_smiles": "O=C1CCCC(=O)C1=NNc1ccc2c(c1)CCN2" + }, + { + "base_idx": 688513, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13 + ], + "molecule_smiles": "C=CCOC(=O)Nc1snnc1C", + "scaffold_smiles": "c1csnn1" + }, + { + "base_idx": 553122, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 30, + 32 + ], + "molecule_smiles": "CCn1c([O-])c(C2=[S+]/C(=C3\\Sc4ccccc4N3C)C(=O)N2C)n(C)c1=S", + "scaffold_smiles": "O=C1NC(c2c[nH]c(=S)[nH]2)=[S+]C1=C1Nc2ccccc2S1" + }, + { + "base_idx": 624977, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 14, + 17, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "CC1=CC(=O)C2=C(C)C[C@@H](O)[C@@H]3[C@@H](OC(=O)[C@H]3C)[C@H]12", + "scaffold_smiles": "O=C1CC2CCC=C3C(=O)C=CC3C2O1" + }, + { + "base_idx": 629013, + "atom_indices": [ + 3, + 8, + 11, + 12, + 13, + 16, + 19, + 21, + 22, + 23, + 24, + 25, + 26, + 29, + 30 + ], + "molecule_smiles": "CC1(C)C[C@]2(CCCN3C(=O)CS[C@@H]32)CCS1", + "scaffold_smiles": "O=C1CSC2N1CCCC21CCSCC1" + }, + { + "base_idx": 170787, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 36, + 37 + ], + "molecule_smiles": "CC1=Nc2[s+]ccn2C(=O)C1=[S@@](=O)([O-])Nc1ccc(N2CCN(S(C)(=O)=O)CC2)cc1", + "scaffold_smiles": "O=C1C(=[SH](=O)Nc2ccc(N3CCNCC3)cc2)C=Nc2[s+]ccn21" + }, + { + "base_idx": 616080, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 22, + 23, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "C[C@@H]1CN(C2CCCCC2)c2nc3c(c(=O)[nH]c(=O)n3C)n2C1", + "scaffold_smiles": "O=c1[nH]c(=O)c2c(nc3n2CCCN3C2CCCCC2)[nH]1" + }, + { + "base_idx": 12250, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 29, + 30, + 31, + 44, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "O=c1cc(-c2ccc(O)c(-c3c(O)cc(O)c4c(=O)cc(-c5ccc(O)cc5)oc34)c2)oc2cc(O)cc(O)c12", + "scaffold_smiles": "O=c1cc(-c2cccc(-c3cccc4c(=O)cc(-c5ccccc5)oc34)c2)oc2ccccc12" + }, + { + "base_idx": 626622, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 21, + 22, + 23, + 26, + 27, + 28 + ], + "molecule_smiles": "C[C@]1(N(O)C=C2C=CC(=O)C=C2)CCCCC1=O", + "scaffold_smiles": "O=C1C=CC(=CNC2CCCCC2=O)C=C1" + }, + { + "base_idx": 660800, + "atom_indices": [ + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 31, + 32, + 33 + ], + "molecule_smiles": "CC(C)(C)OC(=O)CN1c2cccc3cccc(c23)S1(=O)=O", + "scaffold_smiles": "O=S1(=O)Nc2cccc3cccc1c23" + }, + { + "base_idx": 129420, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Nc1nc(CSc2ncn[nH]2)nc(Nc2ccc(F)cc2)n1", + "scaffold_smiles": "c1ccc(Nc2ncnc(CSc3ncn[nH]3)n2)cc1" + }, + { + "base_idx": 293493, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 28 + ], + "molecule_smiles": "COc1ccccc1/N=C\\c1c(C)c(C#N)c2[nH]c3ccccc3n2c1=O", + "scaffold_smiles": "O=c1c(C=Nc2ccccc2)ccc2[nH]c3ccccc3n12" + }, + { + "base_idx": 435931, + "atom_indices": [ + 5, + 6, + 7, + 10, + 11 + ], + "molecule_smiles": "CO[C+]1CC[C@@](O)(CN)O1", + "scaffold_smiles": "[C+]1CCCO1" + }, + { + "base_idx": 150536, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "Cn1cccc1C(=O)NC(=O)COC(=O)c1ccccc1Sc1ccccc1C#N", + "scaffold_smiles": "O=C(COC(=O)c1ccccc1Sc1ccccc1)NC(=O)c1ccc[nH]1" + }, + { + "base_idx": 502877, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "CCOc1ccc(-c2cn(S(=O)(=O)c3cccc4nsnc34)nc2N)cc1", + "scaffold_smiles": "O=S(=O)(c1cccc2nsnc12)n1cc(-c2ccccc2)cn1" + }, + { + "base_idx": 144324, + "atom_indices": [ + 2, + 3, + 7, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 33, + 35, + 37 + ], + "molecule_smiles": "N#CC1=C(N)C(C#N)(C#N)[C@H](c2c(F)cccc2F)[C@H]2CC3(CC=C12)OCCO3", + "scaffold_smiles": "C1=CC2=CCC3(CC2C(c2ccccc2)C1)OCCO3" + }, + { + "base_idx": 26097, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 42, + 43, + 44 + ], + "molecule_smiles": "O=C1CCCC2=C1[C@@H](c1ccc(Cl)cc1)n1c(s/c(=C\\c3ccc4ccncc4c3)c1=O)=N2", + "scaffold_smiles": "O=C1CCCC2=C1C(c1ccccc1)n1c(sc(=Cc3ccc4ccncc4c3)c1=O)=N2" + }, + { + "base_idx": 626655, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 12, + 13, + 14, + 15, + 16, + 19, + 21, + 22, + 23 + ], + "molecule_smiles": "C[C@]1(Cn2ccnn2)[C@@H](C(=O)O)N2C(=O)C[C@@H]2S1(=O)=O", + "scaffold_smiles": "O=C1CC2N1CC(Cn1ccnn1)S2(=O)=O" + }, + { + "base_idx": 685400, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 22 + ], + "molecule_smiles": "C=CCn1c(-c2csc3c2CC[C@@H](C)C3)n[nH]c1=S", + "scaffold_smiles": "S=c1[nH]nc(-c2csc3c2CCCC3)[nH]1" + }, + { + "base_idx": 498638, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 43, + 45 + ], + "molecule_smiles": "CCOc1ccc(CN2CCN(c3cc(C)nc4c5c(C)cc(C)nc5nn34)CC2)cc1", + "scaffold_smiles": "c1ccc(CN2CCN(c3ccnc4c5cccnc5nn34)CC2)cc1" + }, + { + "base_idx": 521757, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 24, + 25, + 26 + ], + "molecule_smiles": "CCOC1=c2c(C)c(C)sc2=N[C@@H](C2C(=O)NC(=S)NC2=O)O1", + "scaffold_smiles": "O=C1NC(=S)NC(=O)C1C1N=c2sccc2=CO1" + }, + { + "base_idx": 623960, + "atom_indices": [ + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "molecule_smiles": "CC1=CS(=O)(=O)C[C@@H]1/[S+]=c1/[nH]nc(C)[nH]1", + "scaffold_smiles": "O=S1(=O)C=CC([S+]=c2[nH]cn[nH]2)C1" + }, + { + "base_idx": 202771, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 21, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "Cc1ccc2c(c1)-c1c(ssc1=S)C(C)(C)N2C(=O)CSC1=NCCS1", + "scaffold_smiles": "O=C(CSC1=NCCS1)N1Cc2ssc(=S)c2-c2ccccc21" + }, + { + "base_idx": 560675, + "atom_indices": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 38 + ], + "molecule_smiles": "CCN(CC)CCNC(=O)CCCNC(=O)Cn1cnc2[s+]c(C)c(C)c-2c1[O-]", + "scaffold_smiles": "c1nc2[s+]ccc-2c[nH]1" + }, + { + "base_idx": 682870, + "atom_indices": [ + 1, + 13, + 14, + 15, + 17, + 23, + 24, + 25 + ], + "molecule_smiles": "C/C(NC(C)C)=C1\\C(=O)O[C@H](C)C1=O", + "scaffold_smiles": "C=C1C(=O)COC1=O" + }, + { + "base_idx": 629281, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 10, + 12, + 13, + 18, + 23 + ], + "molecule_smiles": "CC1(C)C[C@@]2(C)OCCN2C(=S)N1", + "scaffold_smiles": "S=C1NCCC2OCCN12" + }, + { + "base_idx": 309996, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "COc1cccc(CNc2ccc3[nH]c(=O)[nH]c3c2)c1OCc1ccccc1", + "scaffold_smiles": "O=c1[nH]c2ccc(NCc3ccccc3OCc3ccccc3)cc2[nH]1" + }, + { + "base_idx": 512196, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 23 + ], + "molecule_smiles": "CCOC(=O)c1nc2cc3n(c(CC(C)C)c-2c1C)CC(C)=C3C", + "scaffold_smiles": "C1=Cc2cc3nccc-3cn2C1" + }, + { + "base_idx": 23685, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 30 + ], + "molecule_smiles": "O=C1Nc2ccc(Br)cc2/C1=N\\c1ccc(-c2nnc3n2CCCCC3)cc1", + "scaffold_smiles": "O=C1Nc2ccccc2C1=Nc1ccc(-c2nnc3n2CCCCC3)cc1" + }, + { + "base_idx": 131748, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "N[C@@H]1CN(c2c(F)cc3c(=O)c(C(=O)O)cn([C@@H]4C[C@@H]4F)c3c2Cl)CC12CC2", + "scaffold_smiles": "O=c1ccn(C2CC2)c2cc(N3CCC4(CC4)C3)ccc12" + }, + { + "base_idx": 134505, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 26 + ], + "molecule_smiles": "NC(=O)c1cnc2[nH]ccc2c1N[C@@H]1[C@@H]2C[C@@H]3C[C@H]1C[C@@](O)(C3)C2", + "scaffold_smiles": "c1cc(NC2C3CC4CC(C3)CC2C4)c2cc[nH]c2n1" + }, + { + "base_idx": 151692, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 18, + 21, + 22, + 23, + 25, + 26, + 27 + ], + "molecule_smiles": "Cn1cc(-c2cnn3c(N)c(Br)c([C@@H]4CCCNC4)nc23)cn1", + "scaffold_smiles": "c1n[nH]cc1-c1cnn2ccc(C3CCCNC3)nc12" + }, + { + "base_idx": 34370, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "O=C(c1ccc(F)cc1)N1CCCC[C@@H](Sc2nnnn2-c2ccccc2)C1=O", + "scaffold_smiles": "O=C(c1ccccc1)N1CCCCC(Sc2nnnn2-c2ccccc2)C1=O" + }, + { + "base_idx": 61813, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 13, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "O=C(NNC1=NC2(CCCC2)Cc2ccccc21)c1ccncc1", + "scaffold_smiles": "O=C(NNC1=NC2(CCCC2)Cc2ccccc21)c1ccncc1" + }, + { + "base_idx": 439998, + "atom_indices": [ + 7, + 8, + 9, + 19, + 20, + 21 + ], + "molecule_smiles": "COC(=O)C1(C(F)(F)F)N=C(N(C)C)OC(N(C)C)=N1", + "scaffold_smiles": "C1=NCN=CO1" + }, + { + "base_idx": 437547, + "atom_indices": [ + 14, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 32, + 33, + 34, + 40, + 42, + 44, + 46, + 49 + ], + "molecule_smiles": "COC(=O)CCC(=O)O[C@]1(C)C(=O)C=C2C=C(C3CC3)N(C3CCCC3)C=C2C1=O", + "scaffold_smiles": "O=C1C=C2C=C(C3CC3)N(C3CCCC3)C=C2C(=O)C1" + }, + { + "base_idx": 680690, + "atom_indices": [ + 3, + 4, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 25, + 26, + 27, + 28, + 30, + 33, + 34, + 35, + 40, + 41, + 43, + 45, + 53, + 54 + ], + "molecule_smiles": "CC(=O)N1CCC2(CC1)CC(=O)c1cc(OCC(=O)N3CCC4(CC3)OCCO4)ccc1O2", + "scaffold_smiles": "O=C1CC2(CCNCC2)Oc2ccc(OCC(=O)N3CCC4(CC3)OCCO4)cc21" + }, + { + "base_idx": 18775, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "O=S(=O)(Nc1cccc2c1ccc1cccn12)c1cccs1", + "scaffold_smiles": "O=S(=O)(Nc1cccc2c1ccc1cccn12)c1cccs1" + }, + { + "base_idx": 12053, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "O=c1c2ccccc2nc2c3ccccc3c3nncn3n12", + "scaffold_smiles": "O=c1c2ccccc2nc2c3ccccc3c3nncn3n12" + }, + { + "base_idx": 8042, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "OCCNc1nc(-c2cccnc2)nc2ccccc12", + "scaffold_smiles": "c1cncc(-c2ncc3ccccc3n2)c1" + }, + { + "base_idx": 532720, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "CCOC(=O)CNS(=O)(=O)c1c(C)noc1C", + "scaffold_smiles": "c1cnoc1" + }, + { + "base_idx": 477337, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 42, + 57 + ], + "molecule_smiles": "CCc1ccc(NC(=S)Nc2cc(C(=O)O)ccc2N2C[C@@H]3C[C@H](C2)c2cccc(=O)n2C3)cc1", + "scaffold_smiles": "O=c1cccc2n1CC1CC2CN(c2ccccc2NC(=S)Nc2ccccc2)C1" + }, + { + "base_idx": 405, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 26, + 33, + 34, + 35, + 36, + 37, + 39, + 42, + 44, + 45 + ], + "molecule_smiles": "c1cnc2ccc([C@@H](c3nnnn3C3CCCC3)N3CCN4CCC[C@@H]4C3)cc2c1", + "scaffold_smiles": "c1cnc2ccc(C(c3nnnn3C3CCCC3)N3CCN4CCCC4C3)cc2c1" + }, + { + "base_idx": 3817, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 27, + 29, + 32, + 34, + 35, + 37, + 40 + ], + "molecule_smiles": "c1cc2c(cc1C1CCCCC1)c1c3n2CCN[C@H]3CCC1", + "scaffold_smiles": "c1cc2c(cc1C1CCCCC1)c1c3n2CCNC3CCC1" + }, + { + "base_idx": 176337, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "molecule_smiles": "Cc1csc2c(SCC(=O)NNC(=O)C(Cl)Cl)ncnc12", + "scaffold_smiles": "c1ncc2sccc2n1" + }, + { + "base_idx": 137963, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "[H]/N=C1C(=C/c2cccs2)/C(=O)N=C2SN=C(SCc3ccccc3)N2/1", + "scaffold_smiles": "N=C1C(=Cc2cccs2)C(=O)N=C2SN=C(SCc3ccccc3)N12" + }, + { + "base_idx": 696353, + "atom_indices": [ + 1, + 2, + 3, + 4, + 7, + 8, + 10, + 14, + 15, + 16, + 25, + 26, + 27, + 33, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 46, + 47, + 48 + ], + "molecule_smiles": "C/C(CCN1CCCCc2nc(C)c(C)cc21)=N\\OC[C@@H](O)[C@H]1O[C@@H]2OC(C)(C)O[C@@H]2[C@H]1O", + "scaffold_smiles": "C(CCN1CCCCc2ncccc21)=NOCCC1CC2OCOC2O1" + }, + { + "base_idx": 43345, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "O=C(O)Cc1c[s+]c2n1[C@]1(C(=O)[O-])C=CC=C3SC[C@](O)(N2)N31", + "scaffold_smiles": "C1=CC2N3C(=C1)SCC3Nc1[s+]ccn12" + }, + { + "base_idx": 281055, + "atom_indices": [ + 4, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 18, + 23, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 44, + 45, + 46 + ], + "molecule_smiles": "C[C@@H]1C[C@H]2OC(=O)[C@@]3(C)C(=O)CC[C@@](C)([C@@H]23)[C@@]1(O)CCc1ccoc1", + "scaffold_smiles": "O=C1CCC2C(CCc3ccoc3)CCC3OC(=O)C1C32" + }, + { + "base_idx": 651824, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "CC(C)CCn1cnc2c(oc3nc4c(cc32)CCCC4)c1=O", + "scaffold_smiles": "O=c1[nH]cnc2c1oc1nc3c(cc12)CCCC3" + }, + { + "base_idx": 570285, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 40, + 41, + 42, + 43, + 47, + 48 + ], + "molecule_smiles": "CCCn1c([C@H](C)Nc2nc(-c3cc(Cl)c(N)c(Cl)c3)cs2)nc2cc(Cl)c(N3CCCCC3)cc21", + "scaffold_smiles": "c1ccc(-c2csc(NCc3nc4ccc(N5CCCCC5)cc4[nH]3)n2)cc1" + }, + { + "base_idx": 546913, + "atom_indices": [ + 9, + 10, + 11, + 12, + 15, + 18, + 19, + 24, + 27, + 32, + 33 + ], + "molecule_smiles": "CCOC(=O)/C(C#N)=C/C1=C(N2CCCC2)C[C@@](C)(CC)OC1", + "scaffold_smiles": "C1=C(N2CCCC2)CCOC1" + }, + { + "base_idx": 623952, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 32, + 33, + 35 + ], + "molecule_smiles": "[H]/N=c1/c(=C\\c2cccc(OC(=O)c3ccco3)c2)c([O-])nc2[s+]cc(C)n12", + "scaffold_smiles": "N=c1c(=Cc2cccc(OC(=O)c3ccco3)c2)cnc2[s+]ccn12" + }, + { + "base_idx": 4945, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 23, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "S=c1[nH]nc(-c2ccccn2)n1/N=C/c1ccc(-c2ccccc2Br)o1", + "scaffold_smiles": "S=c1[nH]nc(-c2ccccn2)n1N=Cc1ccc(-c2ccccc2)o1" + }, + { + "base_idx": 697537, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 24, + 28, + 29, + 34, + 36, + 39 + ], + "molecule_smiles": "C/C=C(/C)C(=O)O[C@H]1C(C)=C[C@]23C(=O)[C@@H](C=C(CO)[C@@H](O)[C@]12O)[C@H]1[C@@H](C[C@H]3C)C1(C)C", + "scaffold_smiles": "O=C1C2C=CCC3CC=CC13CCC1CC12" + }, + { + "base_idx": 211174, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 12, + 13, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "Cc1ccc([S@@](=O)([O-])=N[S@@+](C)C[C@H]2CC2(Cl)Cl)cc1", + "scaffold_smiles": "O=[SH](=N[S+]CC1CC1)c1ccccc1" + }, + { + "base_idx": 278898, + "atom_indices": [ + 1, + 2, + 4, + 7, + 8, + 9, + 12, + 15, + 17, + 26 + ], + "molecule_smiles": "C[C@H]1CC[C@@]2(O[C@@H](CCO)C[C@@H](O)[C@@H]2O)O1", + "scaffold_smiles": "C1CCC2(CCCO2)OC1" + }, + { + "base_idx": 305281, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 28, + 29, + 31, + 32, + 33, + 34, + 37, + 38, + 39, + 42 + ], + "molecule_smiles": "COc1cccc2cc(C(=O)N3CCN([C@]45C[C@H]6C[C@H](C[C@H](C6)C4)C5)CC3)c(=O)oc12", + "scaffold_smiles": "O=C(c1cc2ccccc2oc1=O)N1CCN(C23CC4CC(CC(C4)C2)C3)CC1" + }, + { + "base_idx": 393459, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 16, + 17, + 18, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 39 + ], + "molecule_smiles": "COc1cc(OC)cc(C(=O)NCCn2c([O-])c(/C=C3\\C=CC=[S+]3)sc2=O)c1", + "scaffold_smiles": "O=C(NCCn1cc(C=C2C=CC=[S+]2)sc1=O)c1ccccc1" + }, + { + "base_idx": 233114, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 42, + 43, + 44 + ], + "molecule_smiles": "Cc1ccc(C)c2[nH]c(=O)c(CN(Cc3ccc4c(c3)OCO4)Cc3nnnn3Cc3ccco3)cc12", + "scaffold_smiles": "O=c1[nH]c2ccccc2cc1CN(Cc1ccc2c(c1)OCO2)Cc1nnnn1Cc1ccco1" + }, + { + "base_idx": 628668, + "atom_indices": [ + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 23, + 26, + 29, + 39 + ], + "molecule_smiles": "CC1(C)CC(NC(=O)[C@@]23CC[C@@](C)(C(=O)C2=O)C3(C)C)CC(C)(C)N1", + "scaffold_smiles": "O=C1C(=O)C2(C(=O)NC3CCNCC3)CCC1C2" + }, + { + "base_idx": 156173, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "Cn1c(=O)c2sccc2n2c(CCCC(=O)N3CCCCC3)nnc12", + "scaffold_smiles": "O=C(CCCc1nnc2[nH]c(=O)c3sccc3n12)N1CCCCC1" + }, + { + "base_idx": 47986, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 23, + 25 + ], + "molecule_smiles": "O=C(O)[C@@H]1[C@@H]2C=C[C@@]3(O2)[C@H]1C(=O)N1c2ccccc2CO[C@@H]13", + "scaffold_smiles": "O=C1C2CC3C=CC2(O3)C2OCc3ccccc3N12" + }, + { + "base_idx": 572657, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 39, + 40, + 41, + 42, + 43, + 44, + 49, + 50, + 51, + 52 + ], + "molecule_smiles": "CCCc1nc(C(C)(C)O)c(C(=O)OCc2oc(=O)oc2C)n1Cc1ccc(-c2ccccc2-c2nnn[nH]2)cc1", + "scaffold_smiles": "O=C(OCc1coc(=O)o1)c1cncn1Cc1ccc(-c2ccccc2-c2nnn[nH]2)cc1" + }, + { + "base_idx": 529678, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "CCOC(=O)Cn1/c(=N/C(=O)c2sc3ccccc3c2Cl)sc2cc(S(N)(=O)=O)ccc21", + "scaffold_smiles": "O=C(N=c1[nH]c2ccccc2s1)c1cc2ccccc2s1" + }, + { + "base_idx": 653145, + "atom_indices": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 32, + 33, + 34 + ], + "molecule_smiles": "CC(C)CC(=O)N[C@@]1(C(F)(F)F)C(=O)Nc2c1c(=O)n(C)c(=O)n2C", + "scaffold_smiles": "O=C1Cc2c([nH]c(=O)[nH]c2=O)N1" + }, + { + "base_idx": 281425, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 24, + 25, + 26, + 27, + 28, + 29, + 37 + ], + "molecule_smiles": "CSc1nnc2c(n1)O[C@H](c1cccn1C)N(C(C)=O)c1ccccc1-2", + "scaffold_smiles": "c1c[nH]c(C2Nc3ccccc3-c3nncnc3O2)c1" + }, + { + "base_idx": 253299, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "Cc1cc(NC(=O)c2cnn3cccnc23)nn1CC(=O)Nc1cccnc1", + "scaffold_smiles": "O=C(Cn1ccc(NC(=O)c2cnn3cccnc23)n1)Nc1cccnc1" + }, + { + "base_idx": 699847, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "CC(NN1CCCCC1)=C1C(=O)NC(=O)NC1=O", + "scaffold_smiles": "O=C1NC(=O)C(=CNN2CCCCC2)C(=O)N1" + }, + { + "base_idx": 677324, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CC(=O)N1N=C(c2ccco2)C[C@@H]1c1ccc(Cl)o1", + "scaffold_smiles": "c1coc(C2=NNC(c3ccco3)C2)c1" + }, + { + "base_idx": 606508, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 17, + 20, + 23 + ], + "molecule_smiles": "CC[C@@H](C)NC(=O)Cn1nc(C(F)(F)F)c2c1CCC2", + "scaffold_smiles": "c1n[nH]c2c1CCC2" + }, + { + "base_idx": 539143, + "atom_indices": [ + 7, + 9, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 28, + 31, + 34 + ], + "molecule_smiles": "CCOC(=O)[C@H]1C(=O)N[C@@]23CCC[C@@H]2[C@@H]1c1ccccc1O3", + "scaffold_smiles": "O=C1CC2c3ccccc3OC3(CCCC23)N1" + }, + { + "base_idx": 26049, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 15, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "O=C1CCC[C@@H]2[C@H]3CCCN4CCC[C@@H](CN12)[C@H]34", + "scaffold_smiles": "O=C1CCCC2C3CCCN4CCCC(CN12)C34" + }, + { + "base_idx": 26778, + "atom_indices": [ + 0, + 1, + 2, + 3, + 6, + 7, + 8, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "O=C1CCCC(O)=C1C(C#Cc1ccccc1)C1=C(O)CCCC1=O", + "scaffold_smiles": "O=C1CCCC=C1C(C#Cc1ccccc1)C1=CCCCC1=O" + }, + { + "base_idx": 555336, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 40, + 41, + 42, + 43, + 44, + 45, + 53, + 54, + 55, + 56 + ], + "molecule_smiles": "CCN(CC)c1ccc2c(c1)Oc1cc(N(CC)CC)ccc1C21OS(=O)(=O)c2cc(S(=O)(=O)[O-])ccc21", + "scaffold_smiles": "O=S1(=O)OC2(c3ccccc3Oc3ccccc32)c2ccccc21" + }, + { + "base_idx": 541726, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 29, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 42, + 43, + 48, + 49 + ], + "molecule_smiles": "CCOC(=O)C1=C(CSc2nc3c(cc2C#N)CN(C)CC3)OC(N)=C(C#N)[C@H]1c1ccncc1", + "scaffold_smiles": "C1=CC(c2ccncc2)C=C(CSc2ccc3c(n2)CCNC3)O1" + }, + { + "base_idx": 644414, + "atom_indices": [ + 13, + 14, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 27, + 40 + ], + "molecule_smiles": "CC(C)OC(=O)C1=C(N)N2C(=O)C[S+]=C2/C(=C(/[O-])OC(C)C)C1", + "scaffold_smiles": "C=C1CC=CN2C(=O)C[S+]=C12" + }, + { + "base_idx": 28056, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 15, + 16, + 18, + 19, + 22 + ], + "molecule_smiles": "O=C1C=C2C=C[C@@H]3C[C@@]2(O1)[C@H]1CCCCN31", + "scaffold_smiles": "O=C1C=C2C=CC3CC2(O1)C1CCCCN31" + }, + { + "base_idx": 273046, + "atom_indices": [ + 1, + 2, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 20, + 21, + 23, + 25, + 28, + 30, + 31, + 34 + ], + "molecule_smiles": "Cc1c(C(=O)O)oc2ccc(S(=O)(=O)NC3CCCCCCC3)cc12", + "scaffold_smiles": "O=S(=O)(NC1CCCCCCC1)c1ccc2occc2c1" + }, + { + "base_idx": 298029, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "COc1ccccc1CNC(=O)C[S@+]([O-])Cc1nc(-c2ccc(OC)c(OC)c2)oc1C", + "scaffold_smiles": "O=C(C[S+]Cc1coc(-c2ccccc2)n1)NCc1ccccc1" + }, + { + "base_idx": 552871, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CCN1C(=O)CS/C1=N\\n1cnnc1", + "scaffold_smiles": "O=C1CSC(=Nn2cnnc2)N1" + }, + { + "base_idx": 473036, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCc1n[nH]c(=O)c2cc3oc(C)cc3n12", + "scaffold_smiles": "O=c1[nH]ncn2c1cc1occc12" + }, + { + "base_idx": 504412, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 42, + 43, + 44, + 45, + 46, + 47 + ], + "molecule_smiles": "CCOc1cc(C(c2c(C)[nH][nH]c2=O)c2c(C)[nH][nH]c2=O)ccc1OS(=O)(=O)c1ccc(C)cc1", + "scaffold_smiles": "O=c1[nH][nH]cc1C(c1ccc(OS(=O)(=O)c2ccccc2)cc1)c1c[nH][nH]c1=O" + }, + { + "base_idx": 461670, + "atom_indices": [ + 1, + 2, + 3, + 4, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 26, + 29, + 33, + 34, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "CN(C(=O)COC(=O)c1ccc2c(=O)n3c(nc2c1)CCC3)[C@H]1CCS(=O)(=O)C1", + "scaffold_smiles": "O=C(COC(=O)c1ccc2c(=O)n3c(nc2c1)CCC3)NC1CCS(=O)(=O)C1" + }, + { + "base_idx": 281780, + "atom_indices": [ + 5, + 6, + 7, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "CSc1nc(NC(C)(C)C)nc(Oc2ccc(=O)[nH]n2)n1", + "scaffold_smiles": "O=c1ccc(Oc2ncncn2)n[nH]1" + }, + { + "base_idx": 661001, + "atom_indices": [ + 5, + 6, + 7, + 8, + 10, + 11, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CC(C)(C)N1NC2(CCCCC2)NC1=S", + "scaffold_smiles": "S=C1NNC2(CCCCC2)N1" + }, + { + "base_idx": 1056, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 32, + 34, + 36, + 37, + 38, + 49, + 50, + 51, + 52, + 53, + 54 + ], + "molecule_smiles": "c1ccc2c(Oc3nc(Nc4ccc(N5CCOCC5)cc4)c4ncn(C5CCCCC5)c4n3)cccc2c1", + "scaffold_smiles": "c1ccc2c(Oc3nc(Nc4ccc(N5CCOCC5)cc4)c4ncn(C5CCCCC5)c4n3)cccc2c1" + }, + { + "base_idx": 462335, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CCn1nc(/C=C/c2ccccc2)nc2c(=O)n(C)c(=O)nc1-2", + "scaffold_smiles": "O=c1nc2[nH]nc(C=Cc3ccccc3)nc-2c(=O)[nH]1" + }, + { + "base_idx": 165333, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 23 + ], + "molecule_smiles": "Cc1nn/c(=[S+]\\CCCCSc2nnc(C)c(=O)[nH]2)[nH]c1[O-]", + "scaffold_smiles": "O=c1cnnc(SCCCC[S+]=c2nncc[nH]2)[nH]1" + }, + { + "base_idx": 1624, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "c1ccc(OCc2nn3c(Cn4cnc5ccccc54)nnc3s2)cc1", + "scaffold_smiles": "c1ccc(OCc2nn3c(Cn4cnc5ccccc54)nnc3s2)cc1" + }, + { + "base_idx": 479532, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 28 + ], + "molecule_smiles": "CCc1cc2c(s1)=[S+]/C(=C(/[O-])Nc1ccccc1F)C=2", + "scaffold_smiles": "C(Nc1ccccc1)=C1C=c2ccsc2=[S+]1" + }, + { + "base_idx": 383175, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 19, + 20, + 21, + 22, + 23, + 24, + 33, + 34, + 37 + ], + "molecule_smiles": "COc1ccc(N2C=C(c3ccc(C)cc3)N3CC[S+]=C32)cc1", + "scaffold_smiles": "C1=C(c2ccccc2)N2CC[S+]=C2N1c1ccccc1" + }, + { + "base_idx": 617344, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 15, + 16, + 17, + 20, + 21, + 22, + 27, + 28, + 29, + 31, + 32, + 34, + 35, + 36, + 37, + 38, + 55, + 56, + 57, + 58, + 59, + 61 + ], + "molecule_smiles": "CC1CCN(c2c(NC[C@H]3CC[C@@H](C(=O)NC4CCCCCC4)CC3)c(=O)c2=O)CC1", + "scaffold_smiles": "O=C(NC1CCCCCC1)C1CCC(CNc2c(N3CCCCC3)c(=O)c2=O)CC1" + }, + { + "base_idx": 33109, + "atom_indices": [ + 0, + 1, + 2, + 3, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 17, + 19, + 25, + 26, + 27, + 28, + 29, + 30, + 34, + 35, + 36, + 37, + 38, + 41, + 42, + 43, + 44, + 45, + 47 + ], + "molecule_smiles": "O=C(c1cccc(NC2=N[C@@H]3CS(=O)(=O)C[C@@H]3S2)c1)N1CCN(c2ncccn2)CC1", + "scaffold_smiles": "O=C(c1cccc(NC2=NC3CS(=O)(=O)CC3S2)c1)N1CCN(c2ncccn2)CC1" + }, + { + "base_idx": 143286, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 23, + 26, + 27, + 28, + 29, + 30, + 31, + 37, + 40 + ], + "molecule_smiles": "N#CC1=C2SC[C@@](O)(c3ccccc3)N2C(=O)C[C@@H]1c1ccccc1F", + "scaffold_smiles": "O=C1CC(c2ccccc2)C=C2SCC(c3ccccc3)N12" + }, + { + "base_idx": 9801, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "molecule_smiles": "O=c1ncnc2[nH][nH]cc1-2", + "scaffold_smiles": "O=c1ncnc2[nH][nH]cc1-2" + }, + { + "base_idx": 137475, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28 + ], + "molecule_smiles": "O=C1C2=Cc3c(n4ccsc4nc3=O)N[C@H]2Oc2ccccc21", + "scaffold_smiles": "O=C1C2=Cc3c(n4ccsc4nc3=O)NC2Oc2ccccc21" + }, + { + "base_idx": 267146, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "Cc1cc(/N=C/C=C2\\N(C)c3ccccc3C2(C)C)n(CCC#N)n1", + "scaffold_smiles": "C(C=C1Cc2ccccc2N1)=Nc1ccn[nH]1" + }, + { + "base_idx": 628634, + "atom_indices": [ + 2, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 30, + 31, + 39, + 40, + 48, + 49, + 59, + 61 + ], + "molecule_smiles": "CC1(C)[C@H]2CC=C(CN3CCC(N4CCC(C(=O)N5CCCC5)CC4)CC3)[C@@H]1C2", + "scaffold_smiles": "O=C(C1CCN(C2CCN(CC3=CCC4CC3C4)CC2)CC1)N1CCCC1" + }, + { + "base_idx": 624488, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "CC1=CC(C)(C)n2c(O)c(C3=NC(=S)NC3=O)c3cc(OC(=O)c4cccc5ccccc45)cc1c32", + "scaffold_smiles": "O=C1NC(=S)N=C1c1cn2c3c(cc(OC(=O)c4cccc5ccccc45)cc13)C=CC2" + }, + { + "base_idx": 688592, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 10, + 15, + 16, + 17, + 20, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "C=CCNc1nc([C@]2(C)C[C@]3(C[C@@H](C)OC3=O)C(=O)O2)cs1", + "scaffold_smiles": "O=C1OCCC12CC(c1cscn1)OC2=O" + }, + { + "base_idx": 178466, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "Cc1ccn2cc(-c3cnoc3C)nc2c1", + "scaffold_smiles": "c1ccn2cc(-c3cnoc3)nc2c1" + }, + { + "base_idx": 641741, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CC(C)c1c(C(=O)N/N=C/c2ccsc2)nnn1-c1nonc1N", + "scaffold_smiles": "O=C(NN=Cc1ccsc1)c1cn(-c2cnon2)nn1" + }, + { + "base_idx": 142967, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 11, + 12, + 13, + 14, + 15, + 16, + 28, + 29, + 30, + 31, + 32, + 34, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 47 + ], + "molecule_smiles": "N#CC1=C(SCC(=O)N[C@]23C[C@H]4C[C@H](C[C@H](C4)C2)C3)NC(=O)[C@H](C#N)C12CCCCC2", + "scaffold_smiles": "O=C1CC2(C=C(SCC(=O)NC34CC5CC(CC(C5)C3)C4)N1)CCCCC2" + }, + { + "base_idx": 519678, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 31, + 32, + 33, + 34, + 36, + 37, + 38, + 41 + ], + "molecule_smiles": "CCOC(=O)c1c(NC(=O)[C@@H]2CC(Cl)=CC[C@H]2C(=O)O)sc2c1CCCCC2", + "scaffold_smiles": "O=C(Nc1cc2c(s1)CCCCC2)C1CC=CCC1" + }, + { + "base_idx": 516951, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "CCOC(=O)c1cc2c(C)[nH]c(C)c2cc(C(=O)OCC)c1=O", + "scaffold_smiles": "O=c1ccc2c[nH]cc2cc1" + }, + { + "base_idx": 472391, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 31, + 32, + 33, + 42 + ], + "molecule_smiles": "CCc1nc(OCc2ccco2)c2sc3nc4c(cc3c2n1)COC(C)(C)C4", + "scaffold_smiles": "c1coc(COc2ncnc3c2sc2nc4c(cc23)COCC4)c1" + }, + { + "base_idx": 447305, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 27, + 28, + 31 + ], + "molecule_smiles": "CN1CCCN(C[C@@H](O)COCc2cccs2)CC1", + "scaffold_smiles": "c1csc(COCCCN2CCCNCC2)c1" + }, + { + "base_idx": 17153, + "atom_indices": [ + 0, + 1, + 2, + 3, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 21, + 24, + 25, + 28, + 30, + 31, + 32 + ], + "molecule_smiles": "O=S1(=O)C[C@@]2(N3CCOCC3)CCCCCC[C@H]21", + "scaffold_smiles": "O=S1(=O)CC2(N3CCOCC3)CCCCCCC21" + }, + { + "base_idx": 590302, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CCCCN(C)S(=O)(=O)c1cc2c3c(c1)CCC(=O)N3CCC2", + "scaffold_smiles": "O=C1CCc2cccc3c2N1CCC3" + }, + { + "base_idx": 600421, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18 + ], + "molecule_smiles": "CCCC(=O)N[C@H]1[C@@H]2C[C@H]3C[C@@H](C2)C[C@@H]1C3", + "scaffold_smiles": "C1C2CC3CC1CC(C2)C3" + }, + { + "base_idx": 692495, + "atom_indices": [ + 0, + 1, + 2, + 4, + 7, + 16, + 17, + 20, + 21, + 22, + 23, + 26, + 29 + ], + "molecule_smiles": "C=C1CC[C@H]2O[C@]2(C)CC[C@@H]2[C@@H]1CC2(C)C", + "scaffold_smiles": "C=C1CCC2OC2CCC2CCC12" + }, + { + "base_idx": 481032, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CCc1[nH]nc2c1[C@H]([C@H]1C=CCCC1)C(C#N)=C(N)O2", + "scaffold_smiles": "C1=CC(C2C=COc3n[nH]cc32)CCC1" + }, + { + "base_idx": 628734, + "atom_indices": [ + 3, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "CC1(C)C[C@H](Cc2nc(=S)[nH][nH]2)C(=O)O1", + "scaffold_smiles": "O=C1OCCC1Cc1nc(=S)[nH][nH]1" + }, + { + "base_idx": 449919, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 15, + 17, + 20, + 23 + ], + "molecule_smiles": "CN(c1ccc(C(N)=O)nn1)C1CCC1", + "scaffold_smiles": "c1cnnc(NC2CCC2)c1" + }, + { + "base_idx": 496267, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CCOc1ccc(N2C(=O)CN(O)C=C3N=c4ccccc4=C32)cc1", + "scaffold_smiles": "O=C1CNC=C2N=c3ccccc3=C2N1c1ccccc1" + }, + { + "base_idx": 541405, + "atom_indices": [ + 7, + 8, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 22, + 23, + 24, + 26, + 29 + ], + "molecule_smiles": "CCOC(=O)C1=C(N)c2ccccc2C[C@H]1C1CCCC1", + "scaffold_smiles": "C1=CC(C2CCCC2)Cc2ccccc21" + }, + { + "base_idx": 527258, + "atom_indices": [ + 7, + 8, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 32, + 33, + 34, + 40, + 41 + ], + "molecule_smiles": "CCOC(=O)N1CCC2(CC1)C(C#N)=C(N)Oc1c2sc2nc(C)cc(C)c12", + "scaffold_smiles": "C1=CC2(CCNCC2)c2sc3ncccc3c2O1" + }, + { + "base_idx": 623882, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 47 + ], + "molecule_smiles": "CC1=NN(/C(=N\\c2nnc(C)s2)Nc2ccc(C)cc2)[C@H](c2ccc(C)cc2)S1", + "scaffold_smiles": "C1=NN(C(=Nc2nncs2)Nc2ccccc2)C(c2ccccc2)S1" + }, + { + "base_idx": 518764, + "atom_indices": [ + 7, + 8, + 12, + 13, + 14, + 15, + 17, + 18, + 19 + ], + "molecule_smiles": "CCOC(=O)C1=C2CCCC([O-])=C2[S+]=C1N", + "scaffold_smiles": "C1=[S+]C2=CCCCC2=C1" + }, + { + "base_idx": 696175, + "atom_indices": [ + 13, + 14, + 15, + 16, + 17, + 20, + 29, + 38, + 39, + 40 + ], + "molecule_smiles": "C/C(C[C@@H]1CC2=C(CC1(C)C)C(C)(C)CCC2)=N\\NC(N)=S", + "scaffold_smiles": "C1CCC2=C(C1)CCCC2" + }, + { + "base_idx": 446653, + "atom_indices": [ + 1, + 2, + 3, + 6, + 9, + 10, + 11, + 12, + 15, + 16 + ], + "molecule_smiles": "CN1CC[C@@]2(O)OCCC[C@H]2C1", + "scaffold_smiles": "C1COC2CCNCC2C1" + }, + { + "base_idx": 519625, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 32, + 33, + 34, + 39 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)/C(=C(/C)[O-])[S+]=C1NC(=O)COc1ncnc2ccccc12", + "scaffold_smiles": "C=C1C=CC(NC(=O)COc2ncnc3ccccc23)=[S+]1" + }, + { + "base_idx": 439627, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "COC(=O)/C=c1\\sc2ncnn2c1=O", + "scaffold_smiles": "C=c1sc2ncnn2c1=O" + }, + { + "base_idx": 431096, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 37, + 38, + 39 + ], + "molecule_smiles": "COC(=O)c1c(NC(=O)CN2C(=O)C(=O)N(CC(C)C)C2=O)sc(C)c1C", + "scaffold_smiles": "O=C(CN1C(=O)NC(=O)C1=O)Nc1cccs1" + }, + { + "base_idx": 271850, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 26, + 32 + ], + "molecule_smiles": "Cc1c(CCO)[s+]cn1CC(=O)OCC[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2", + "scaffold_smiles": "O=C(Cn1cc[s+]c1)OCCC12CC3CC(CC(C3)C1)C2" + }, + { + "base_idx": 656511, + "atom_indices": [ + 4, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 27, + 31, + 32, + 33, + 36, + 39, + 42, + 43 + ], + "molecule_smiles": "CC(C)(O[C@@H]1O[C@H](CO)[C@@H](O)[C@H](O)[C@H]1O)[C@H]1Oc2ccc3ccc(=O)oc3c2[C@H]1O", + "scaffold_smiles": "O=c1ccc2ccc3c(c2o1)CC(COC1CCCCO1)O3" + }, + { + "base_idx": 4892, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "molecule_smiles": "S=c1[nH]c2ccccc2o1", + "scaffold_smiles": "S=c1[nH]c2ccccc2o1" + }, + { + "base_idx": 249241, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 14 + ], + "molecule_smiles": "Cc1cc2n(c1)CCn1c(C)ccc1-2", + "scaffold_smiles": "c1cc2n(c1)CCn1cccc1-2" + }, + { + "base_idx": 572368, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "CCCc1cc(-c2ccc3c(c2)COc2c4ccccc4nn2-3)on1", + "scaffold_smiles": "c1ccc2c3n(nc2c1)-c1ccc(-c2ccno2)cc1CO3" + }, + { + "base_idx": 143683, + "atom_indices": [ + 2, + 3, + 4, + 5, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 24, + 28, + 29, + 31, + 33, + 34, + 36 + ], + "molecule_smiles": "N#Cc1c(N2CCOCC2)nc(N)c2c(N)nc3c(c12)C[C@@H](CCl)O3", + "scaffold_smiles": "c1nc(N2CCOCC2)cc2c3c(ncc12)OCC3" + }, + { + "base_idx": 622824, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 24, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 40 + ], + "molecule_smiles": "C[C@H]1CC2=C(NC(=S)N[C@@H]2c2ccc(F)cc2)/C(=C/c2ccc(F)cc2)C1", + "scaffold_smiles": "S=C1NC2=C(CCCC2=Cc2ccccc2)C(c2ccccc2)N1" + }, + { + "base_idx": 409946, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 29, + 33, + 34, + 35, + 36, + 37, + 38 + ], + "molecule_smiles": "COCC1=[S+]/C(=C(/[O-])N2CCc3c([nH]c4ccccc34)[C@H]2c2ccccn2)C=C1", + "scaffold_smiles": "C1=CC(=CN2CCc3c([nH]c4ccccc34)C2c2ccccn2)[S+]=C1" + }, + { + "base_idx": 38210, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 27, + 28, + 29, + 30, + 31, + 32, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "O=C(Oc1ccc(N(Cc2cccs2)C2=NS(=O)(=O)c3ccccc32)cc1)c1ccccc1", + "scaffold_smiles": "O=C(Oc1ccc(N(Cc2cccs2)C2=NS(=O)(=O)c3ccccc32)cc1)c1ccccc1" + }, + { + "base_idx": 192862, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 31, + 32, + 33, + 34, + 35, + 48, + 50 + ], + "molecule_smiles": "Cc1cccc(N2CCN(C(=O)CCNS(=O)(=O)c3ccc4c(c3)CCC(=O)N4)CC2)c1C", + "scaffold_smiles": "O=C1CCc2cc(S(=O)(=O)NCCC(=O)N3CCN(c4ccccc4)CC3)ccc2N1" + }, + { + "base_idx": 520916, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "CCOC(=O)c1c(C)oc2c1c(/C=N/Cc1ccccn1)c(O)c1ccccc12", + "scaffold_smiles": "C(=NCc1ccccn1)c1cc2ccccc2c2occc12" + }, + { + "base_idx": 602010, + "atom_indices": [ + 5, + 6, + 7, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 42, + 43, + 44, + 45, + 46, + 47, + 48 + ], + "molecule_smiles": "CCC1=C2CC[C@H]3C(=O)N(CC)C(=O)[C@H]3[C@H]2[C@@]2(Cc3ccccc3)/C(=N/CCOC)OC(=O)N2C1=O", + "scaffold_smiles": "N=C1OC(=O)N2C(=O)C=C3CCC4C(=O)NC(=O)C4C3C12Cc1ccccc1" + }, + { + "base_idx": 45414, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "O=C(O)CCc1nn2c3ccccc3n(CCN3CCCCC3)c2nc1=O", + "scaffold_smiles": "O=c1cnn2c3ccccc3n(CCN3CCCCC3)c2n1" + }, + { + "base_idx": 251438, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 26, + 35, + 38 + ], + "molecule_smiles": "Cc1cc(OCCCS(C)(=O)=O)cc(C)c1-c1cccc(COc2ccc3c(c2)OC[C@H]3CC(=O)O)c1", + "scaffold_smiles": "c1ccc(-c2cccc(COc3ccc4c(c3)OCC4)c2)cc1" + }, + { + "base_idx": 162793, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 18, + 19, + 20, + 24, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "Cc1oc(C)c2c(=O)ccc3c(c12)OC(N)=C(C#N)[C@H]3c1ccccc1Cl", + "scaffold_smiles": "O=c1ccc2c(c3cocc13)OC=CC2c1ccccc1" + }, + { + "base_idx": 256980, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "Cc1cc(C=C2C(=O)N(C)C(=S)N(C)C2=O)c(C)n1-c1cccnc1", + "scaffold_smiles": "O=C1NC(=S)NC(=O)C1=Cc1ccn(-c2cccnc2)c1" + }, + { + "base_idx": 76746, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 50 + ], + "molecule_smiles": "O=C(NC1CC1)C1=C[C@H](c2csc3ccccc23)C[C@H](OCc2ccc(CO)cc2)O1", + "scaffold_smiles": "O=C(NC1CC1)C1=CC(c2csc3ccccc23)CC(OCc2ccccc2)O1" + }, + { + "base_idx": 694300, + "atom_indices": [ + 0, + 3, + 4, + 6, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 24, + 25, + 27, + 28 + ], + "molecule_smiles": "C1CCCCCC(=NNc2nn[nH]n2)CCCCC1", + "scaffold_smiles": "C1CCCCCC(=NNc2nn[nH]n2)CCCCC1" + }, + { + "base_idx": 158141, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 36 + ], + "molecule_smiles": "Cn1c2n/c(=[S+]\\C3C(=O)CC(C)(C)CC3=O)n(C)c-2c([O-])n(C)c1=O", + "scaffold_smiles": "O=C1CCCC(=O)C1[S+]=c1nc2[nH]c(=O)[nH]cc-2[nH]1" + }, + { + "base_idx": 509894, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CCOCCCNC(=O)c1ccc2c(c1)N=C(CC)c1cc(Br)ccc1S2", + "scaffold_smiles": "C1=Nc2ccccc2Sc2ccccc21" + }, + { + "base_idx": 422359, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "COC(=O)C1=[S+]C=C/C1=c1\\cc/c(=C(\\N)[O-])o1", + "scaffold_smiles": "C=c1ccc(=C2C=C[S+]=C2)o1" + }, + { + "base_idx": 32599, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48 + ], + "molecule_smiles": "O=C(c1ccccc1)[C@@H]1[C@@H](C(=O)c2ccc(F)cc2)[C@@]2(C(=O)Nc3ccccc32)[C@H]2c3ccccc3C=CN12", + "scaffold_smiles": "O=C(c1ccccc1)C1C(C(=O)c2ccccc2)C2(C(=O)Nc3ccccc32)C2c3ccccc3C=CN12" + }, + { + "base_idx": 273397, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Cc1c(C#N)c(O)n(CCO)c(=O)c1/N=N\\c1ccc2c(c1)COC2=O", + "scaffold_smiles": "O=C1OCc2cc(N=Nc3ccc[nH]c3=O)ccc21" + }, + { + "base_idx": 597950, + "atom_indices": [ + 5, + 6, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "CCCC1=C(C(=O)OCC)[C@@H](c2ccncc2)n2ncnc2N1", + "scaffold_smiles": "C1=CC(c2ccncc2)n2ncnc2N1" + }, + { + "base_idx": 170819, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "Cc1nc2[s+]cc(-c3ccccc3)c-2c([O-])n1N=C1C=CC(=O)C=C1", + "scaffold_smiles": "O=C1C=CC(=Nn2cnc3[s+]cc(-c4ccccc4)c-3c2)C=C1" + }, + { + "base_idx": 513284, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17 + ], + "molecule_smiles": "CCOC(=O)c1cnc2[s+]ccn2c1[O-]", + "scaffold_smiles": "c1cnc2[s+]ccn2c1" + }, + { + "base_idx": 174126, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 17, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "CC1=NC(c2ccccc2)=[S+]/C1=C(\\[O-])CSc1ccc(Cl)cc1", + "scaffold_smiles": "C1=NC(c2ccccc2)=[S+]C1=CCSc1ccccc1" + }, + { + "base_idx": 127487, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 21, + 28 + ], + "molecule_smiles": "Nc1nnc(CCCCCc2nnc(N)s2)s1", + "scaffold_smiles": "c1nnc(CCCCCc2nncs2)s1" + }, + { + "base_idx": 279420, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 17, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "C[C@H](N)C(=O)N[C@@]1(C(=O)O)C[C@@H](Sc2nnc[nH]2)[C@H]2[C@H](C(=O)O)[C@H]21", + "scaffold_smiles": "c1nnc(SC2CCC3CC32)[nH]1" + }, + { + "base_idx": 137190, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 21, + 22, + 23, + 24, + 27, + 30 + ], + "molecule_smiles": "[H]/N=c1/c2c3c(sc2ncn1Nc1ccccc1)CCC3", + "scaffold_smiles": "N=c1c2c3c(sc2ncn1Nc1ccccc1)CCC3" + }, + { + "base_idx": 406809, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 19, + 20, + 21, + 22, + 23, + 26, + 27, + 28, + 29, + 32, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "COc1cc(-c2[nH]ncc2CN2CCC[C@H]2c2nonc2C)cc(OC)c1OC", + "scaffold_smiles": "c1ccc(-c2[nH]ncc2CN2CCCC2c2cnon2)cc1" + }, + { + "base_idx": 149690, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Cn1nccc1C(=O)Nc1ccc(OC(=O)c2ccnn2C)cc1", + "scaffold_smiles": "O=C(Nc1ccc(OC(=O)c2ccn[nH]2)cc1)c1ccn[nH]1" + }, + { + "base_idx": 553166, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 29, + 30 + ], + "molecule_smiles": "CCN1C(=O)/C(=C\\C=C2/C=CC=CN2CC)SC1=S", + "scaffold_smiles": "O=C1NC(=S)SC1=CC=C1C=CC=CN1" + }, + { + "base_idx": 561768, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 23 + ], + "molecule_smiles": "CCN(CC)C(=O)c1cc2sc3ccccc3c2s1", + "scaffold_smiles": "c1ccc2c(c1)sc1ccsc12" + }, + { + "base_idx": 171324, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 24, + 25, + 26, + 29, + 30, + 31, + 32, + 34, + 37, + 38, + 40, + 42, + 44, + 49, + 50 + ], + "molecule_smiles": "Cc1nc2ncnn2c(N2CCC(C(=O)NC[C@H]3CCCO3)CC2)c1Cc1ccccc1", + "scaffold_smiles": "O=C(NCC1CCCO1)C1CCN(c2c(Cc3ccccc3)cnc3ncnn23)CC1" + }, + { + "base_idx": 175499, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 13, + 16, + 19, + 20, + 21, + 22, + 23, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "Cc1nc(CCNC(=O)C[C@H]2C(=O)NCCN2CC(C)C)nc2c1CCC2", + "scaffold_smiles": "O=C(CC1NCCNC1=O)NCCc1ncc2c(n1)CCC2" + }, + { + "base_idx": 257435, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "Cc1cc(C)nc(/N=C2\\NC(=O)C[C@H]3C(=O)NCCN23)n1", + "scaffold_smiles": "O=C1CC2C(=O)NCCN2C(=Nc2ncccn2)N1" + }, + { + "base_idx": 21071, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 44, + 46, + 49 + ], + "molecule_smiles": "O=S(=O)(N1CCCCC1)N1CCCN(S(=O)(=O)N2CCCCC2)CC1", + "scaffold_smiles": "O=S(=O)(N1CCCCC1)N1CCCN(S(=O)(=O)N2CCCCC2)CC1" + }, + { + "base_idx": 684413, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 18, + 19, + 26, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "C=CCn1c(SCc2c(C)noc2C)nc2[s+]c3c(c-2c1[O-])CC[C@H](C)C3", + "scaffold_smiles": "c1nocc1CSc1nc2[s+]c3c(c-2c[nH]1)CCCC3" + }, + { + "base_idx": 265406, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "molecule_smiles": "Cc1cc(C(=O)N2CC[C@@H](C(F)(F)F)n3nccc32)on1", + "scaffold_smiles": "O=C(c1ccno1)N1CCCn2nccc21" + }, + { + "base_idx": 289263, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 50, + 51, + 52, + 55, + 56, + 57, + 58, + 60, + 61, + 62 + ], + "molecule_smiles": "COc1ncc(-c2ccc3c(Nc4cc(Oc5cc(F)cc(F)c5)cc(C(=O)O)c4)c(S(=O)(=O)NC4CC4)cnc3c2)c(OC)n1", + "scaffold_smiles": "O=S(=O)(NC1CC1)c1cnc2cc(-c3cncnc3)ccc2c1Nc1cccc(Oc2ccccc2)c1" + }, + { + "base_idx": 175532, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "CC1=N/C(=[S+]\\CCn2c([O-])c3ccccc3c2[O-])[S+]=C1", + "scaffold_smiles": "C1=NC(=[S+]CCn2cc3ccccc3c2)[S+]=C1" + }, + { + "base_idx": 512177, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "CCOC(=O)c1nc2nc(C)c(Cl)c(=O)n2[nH]1", + "scaffold_smiles": "O=c1ccnc2nc[nH]n12" + }, + { + "base_idx": 25441, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "O=C1COc2ccc(C(=O)COC(=O)CNC(=O)c3ccco3)cc2N1", + "scaffold_smiles": "O=C1COc2ccc(C(=O)COC(=O)CNC(=O)c3ccco3)cc2N1" + }, + { + "base_idx": 21228, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "O=CC1=C(c2ccccc2)Oc2ccccc2O1", + "scaffold_smiles": "C1=C(c2ccccc2)Oc2ccccc2O1" + }, + { + "base_idx": 255986, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 45, + 49, + 50, + 52, + 53, + 58, + 59 + ], + "molecule_smiles": "Cc1cc(N2CCN(C)CC2)nc2ccc(NC(=S)NCCCN3CCc4ccccc4C3)cc12", + "scaffold_smiles": "S=C(NCCCN1CCc2ccccc2C1)Nc1ccc2nc(N3CCNCC3)ccc2c1" + }, + { + "base_idx": 23267, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 22, + 23, + 24, + 25, + 27, + 28 + ], + "molecule_smiles": "O=C1O[C@@H](Cc2ccncc2)C(=O)C1=C1NCCCN1", + "scaffold_smiles": "O=C1OC(Cc2ccncc2)C(=O)C1=C1NCCCN1" + }, + { + "base_idx": 279004, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 25, + 32, + 33, + 35, + 38 + ], + "molecule_smiles": "C[C@]12Cc3c[nH]nc3C[C@@H]1CC[C@@H]1[C@@H]2CC[C@@]2(C)[C@H]1CC[C@]2(C)O", + "scaffold_smiles": "c1[nH]nc2c1CC1C(CCC3C4CCCC4CCC13)C2" + }, + { + "base_idx": 471472, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CCc1nc2c3cnn(-c4cc(Cl)ccc4C)c3ncn2n1", + "scaffold_smiles": "c1ccc(-n2ncc3c2ncn2ncnc32)cc1" + }, + { + "base_idx": 270517, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 36 + ], + "molecule_smiles": "Cc1c(NC(=O)CSCc2ccco2)c(=O)n(-c2ccccc2)n1C", + "scaffold_smiles": "O=C(CSCc1ccco1)Nc1c[nH]n(-c2ccccc2)c1=O" + }, + { + "base_idx": 260808, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 23, + 25, + 26, + 27, + 29, + 30, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "Cc1cc(C)cc(N(CC(=O)NCCSc2ccccn2)S(=O)(=O)c2ccccc2)c1", + "scaffold_smiles": "O=C(CN(c1ccccc1)S(=O)(=O)c1ccccc1)NCCSc1ccccn1" + }, + { + "base_idx": 248378, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 31, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "Cc1cc2c(cc1C)N[C@H](c1nc3ccccc3[nH]1)C1=C(CC(C)(C)CC1=O)N2", + "scaffold_smiles": "O=C1CCCC2=C1C(c1nc3ccccc3[nH]1)Nc1ccccc1N2" + }, + { + "base_idx": 254112, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 14, + 23, + 24, + 25 + ], + "molecule_smiles": "CC1=CC2=NC=C3C(=O)CC(C)(C)C[C@@]3(O)N2O1", + "scaffold_smiles": "O=C1CCCC2C1=CN=C1C=CON12" + }, + { + "base_idx": 269860, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 18, + 21, + 22, + 23, + 26, + 27, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 50, + 51, + 59 + ], + "molecule_smiles": "Cc1cc(-c2ccc3c(c2)CC[C@H]3N2CC3(CCN(C(=O)Cc4cn5cc(C)sc5n4)CC3)C2)ncn1", + "scaffold_smiles": "O=C(Cc1cn2ccsc2n1)N1CCC2(CC1)CN(C1CCc3cc(-c4ccncn4)ccc31)C2" + }, + { + "base_idx": 540698, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 36, + 37, + 38, + 39, + 40, + 41 + ], + "molecule_smiles": "CCOC(=O)C1=NN(c2cccc(Cl)c2)C2=NC(C)=C(C(=O)OC)[C@H](c3cccs3)N12", + "scaffold_smiles": "C1=CC(c2cccs2)N2C=NN(c3ccccc3)C2=N1" + }, + { + "base_idx": 174302, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 26 + ], + "molecule_smiles": "Cc1nc(C)c(-c2ccn(C(=O)NC(C)C)n2)s1", + "scaffold_smiles": "c1cc(-c2cncs2)n[nH]1" + }, + { + "base_idx": 174454, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 25, + 26 + ], + "molecule_smiles": "Cc1nc(-c2coc3cc(C)c(N=O)c(O)c3/c2=N\\N)cs1", + "scaffold_smiles": "N=c1c(-c2cscn2)coc2ccccc12" + }, + { + "base_idx": 136899, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "NC(=O)N/N=C1/c2ccccc2-c2nc3ccccc3nc21", + "scaffold_smiles": "N=C1c2ccccc2-c2nc3ccccc3nc21" + }, + { + "base_idx": 526329, + "atom_indices": [ + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 52, + 54 + ], + "molecule_smiles": "CCOC(=O)N1CCN(C(=O)c2ccc3c(c2)N(Cc2cccc(Cl)c2)C(=O)c2ccccc2[S@+]3[O-])CC1", + "scaffold_smiles": "O=C(c1ccc2c(c1)N(Cc1ccccc1)C(=O)c1ccccc1[S+]2)N1CCNCC1" + }, + { + "base_idx": 672972, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 25, + 29, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 48, + 49, + 50, + 51, + 52, + 53 + ], + "molecule_smiles": "CC(=O)Nc1ccc(SC[C@H](c2ccccc2)N2CCN(CCc3ccccc3)CCC2=O)cc1", + "scaffold_smiles": "O=C1CCN(CCc2ccccc2)CCN1C(CSc1ccccc1)c1ccccc1" + }, + { + "base_idx": 155499, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "Cn1c(=O)oc2cc(S(=O)(=O)NCc3cccnc3)ccc21", + "scaffold_smiles": "O=c1[nH]c2ccc(S(=O)(=O)NCc3cccnc3)cc2o1" + }, + { + "base_idx": 161171, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 16, + 23, + 24, + 27, + 28, + 29 + ], + "molecule_smiles": "Cc1sc2nc3ccc(N4CCCC4)nn3c(=O)c2c1C", + "scaffold_smiles": "O=c1c2ccsc2nc2ccc(N3CCCC3)nn12" + }, + { + "base_idx": 249880, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 14 + ], + "molecule_smiles": "Cc1cc2c(c(=O)o1)[C@H](C(C)C)C(C#N)=C(N)O2", + "scaffold_smiles": "O=c1occc2c1CC=CO2" + }, + { + "base_idx": 197770, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 23, + 24, + 27, + 29, + 30, + 32, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "Cc1cccc(/C=N/Nc2cc(N3CCOCC3)n3nc(-c4ccncc4)cc3n2)c1", + "scaffold_smiles": "C(=NNc1cc(N2CCOCC2)n2nc(-c3ccncc3)cc2n1)c1ccccc1" + }, + { + "base_idx": 260972, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "Cc1cc(C)c2c(c1)c(C)cc1nnc(SCC(=O)c3cccs3)n12", + "scaffold_smiles": "O=C(CSc1nnc2ccc3ccccc3n12)c1cccs1" + }, + { + "base_idx": 480883, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CCn1c(C)cc2c3ccccc3[s+]c-2c1C", + "scaffold_smiles": "c1ccc2c3cc[nH]cc-3[s+]c2c1" + }, + { + "base_idx": 445610, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "CN1C2=CC(=O)/C(=N\\NC(N)=O)C=C2C[C@@H]1S(=O)(=O)[O-]", + "scaffold_smiles": "N=C1C=C2CCNC2=CC1=O" + }, + { + "base_idx": 140595, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14 + ], + "molecule_smiles": "N#Cc1cnn2c(C3CC3)ccnc12", + "scaffold_smiles": "c1cc(C2CC2)n2nccc2n1" + }, + { + "base_idx": 172447, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 13, + 14, + 15, + 18, + 19, + 20 + ], + "molecule_smiles": "CC1=[S+]c2c/c(=C(/[O-])N3CCSCC3)ccc2=N1", + "scaffold_smiles": "C1=[S+]c2cc(=CN3CCSCC3)ccc2=N1" + }, + { + "base_idx": 483554, + "atom_indices": [ + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 22, + 24, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCN(CC)CCN(C(=O)C1=COCCO1)c1[nH]c2cc(C)cc(C)c2[s+]1", + "scaffold_smiles": "O=C(Nc1[nH]c2ccccc2[s+]1)C1=COCCO1" + }, + { + "base_idx": 9621, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 23, + 24, + 25, + 28, + 31, + 34, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "OCC[C@@H]1CN(Cc2cccc(OC3CCCC3)c2)CCN1Cc1ccsc1", + "scaffold_smiles": "c1cc(CN2CCN(Cc3ccsc3)CC2)cc(OC2CCCC2)c1" + }, + { + "base_idx": 546254, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27 + ], + "molecule_smiles": "CCO/N=C1/CS[C@H]2c3ccccc3C(=O)N2Cc2sccc21", + "scaffold_smiles": "N=C1CSC2c3ccccc3C(=O)N2Cc2sccc21" + }, + { + "base_idx": 684451, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 25, + 26, + 27, + 28, + 39, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "C=CCn1c(SCC2=C(C(=O)OCC)[C@H](C)NC(=O)N2)nnc1C1CCCCC1", + "scaffold_smiles": "O=C1NCC=C(CSc2nnc(C3CCCCC3)[nH]2)N1" + }, + { + "base_idx": 149789, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "Cn1cnnc1/[S+]=C1\\C=C/C(=C/C(CCC(=O)O)=C2/N=c3ccccc3=[S+]2)C=C1N([O-])[O-]", + "scaffold_smiles": "C1=CC(=[S+]c2nnc[nH]2)C=CC1=CC=C1N=c2ccccc2=[S+]1" + }, + { + "base_idx": 438227, + "atom_indices": [ + 7, + 9, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "COC(=O)[C@H]1C(=O)c2c([nH]c(C(=O)OC3CCCCC3)c2C)C[C@@H]1C", + "scaffold_smiles": "O=C(OC1CCCCC1)c1cc2c([nH]1)CCCC2=O" + }, + { + "base_idx": 422552, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 22, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CO[C@@]12[C@@H](COC(N)=O)C3=C(C(=O)C(C)=C(N)C3=O)N1C[C@@H]1N[C@@H]12", + "scaffold_smiles": "O=C1C=CC(=O)C2=C1CC1C3NC3CN21" + }, + { + "base_idx": 481332, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 30, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCc1c2c(nc3ccc(O)cc13)-c1cc3c(c(=O)n1C2)COC(=O)[C@]3(O)CC", + "scaffold_smiles": "O=C1Cc2cc3n(c(=O)c2CO1)Cc1cc2ccccc2nc1-3" + }, + { + "base_idx": 551171, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15 + ], + "molecule_smiles": "CCN1C(=O)N(CC)[C@H]2NC(=S)N[C@H]21", + "scaffold_smiles": "O=C1NC2NC(=S)NC2N1" + }, + { + "base_idx": 644401, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 23, + 24, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 43, + 44, + 45, + 46, + 47, + 48 + ], + "molecule_smiles": "CC(C)O[P@@](=O)(c1ccc(N(C)C)cc1)[C@@H](Nc1ccccc1)c1ccccc1", + "scaffold_smiles": "O=[PH](c1ccccc1)C(Nc1ccccc1)c1ccccc1" + }, + { + "base_idx": 595386, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 17 + ], + "molecule_smiles": "CCC[C@H]1Cc2[nH]c(=O)c(C#N)cc2CO1", + "scaffold_smiles": "O=c1ccc2c([nH]1)CCOC2" + }, + { + "base_idx": 702036, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "molecule_smiles": "C#Cc1cnc2nc(O)n(C(CC)CC)c2n1", + "scaffold_smiles": "c1cnc2[nH]cnc2n1" + }, + { + "base_idx": 703591, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13, + 14, + 18, + 19, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 51, + 61 + ], + "molecule_smiles": "C=C(C)C(=O)Nc1cccc(C2=NO[C@@]3(C2)C[C@H](C(N)=O)N(C(=O)c2ccccc2C(=O)c2ccccc2)C3)c1", + "scaffold_smiles": "O=C(c1ccccc1)c1ccccc1C(=O)N1CCC2(CC(c3ccccc3)=NO2)C1" + }, + { + "base_idx": 268005, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 25, + 26, + 28 + ], + "molecule_smiles": "Cc1cc(-n2c(C)cc(C(=O)C/[S+]=c3\\nc(N)cc([O-])[nH]3)c2C)no1", + "scaffold_smiles": "O=C(C[S+]=c1nccc[nH]1)c1ccn(-c2ccon2)c1" + }, + { + "base_idx": 449981, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "CN(Cc1nccs1)C(=O)c1cc(COc2cccc3cnccc23)on1", + "scaffold_smiles": "O=C(NCc1nccs1)c1cc(COc2cccc3cnccc23)on1" + }, + { + "base_idx": 213668, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 26, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "Cc1ccc(S(=O)(=O)Cn2nnnc2CN2CCCC3(CCCCC3)C2)cc1", + "scaffold_smiles": "O=S(=O)(Cn1nnnc1CN1CCCC2(CCCCC2)C1)c1ccccc1" + }, + { + "base_idx": 284364, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "CSC1=NC2(CCCCC2)C2=C(CCCC2)N1", + "scaffold_smiles": "C1=NC2(CCCCC2)C2=C(CCCC2)N1" + }, + { + "base_idx": 683138, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "C=Cn1ccnc1P(=S)(c1ccccc1)c1nccn1C=C", + "scaffold_smiles": "S=P(c1ccccc1)(c1ncc[nH]1)c1ncc[nH]1" + }, + { + "base_idx": 4527, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 17, + 18, + 19, + 20, + 21, + 22, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "c1ccc(C(c2ccccc2)(c2ccccc2)c2cnno2)cc1", + "scaffold_smiles": "c1ccc(C(c2ccccc2)(c2ccccc2)c2cnno2)cc1" + }, + { + "base_idx": 171734, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 13 + ], + "molecule_smiles": "Cc1nc2ccc3[nH]snc3c2n1", + "scaffold_smiles": "c1nc2ccc3[nH]snc3c2n1" + }, + { + "base_idx": 163720, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "Cc1oc(-c2ccccc2Cl)cc1-c1nn2c(-c3ccco3)nnc2s1", + "scaffold_smiles": "c1ccc(-c2cc(-c3nn4c(-c5ccco5)nnc4s3)co2)cc1" + }, + { + "base_idx": 376948, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 28, + 29 + ], + "molecule_smiles": "COc1ccc(C(=O)/C=C/c2ccc(OC3CSC3)cc2)cc1", + "scaffold_smiles": "O=C(C=Cc1ccc(OC2CSC2)cc1)c1ccccc1" + }, + { + "base_idx": 624550, + "atom_indices": [ + 1, + 2, + 3, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 35, + 36, + 37 + ], + "molecule_smiles": "CC1=C[C@@H](C/C(C)=C/COc2ccc3ccc(=O)oc3c2)OC1=O", + "scaffold_smiles": "O=C1C=CC(CC=CCOc2ccc3ccc(=O)oc3c2)O1" + }, + { + "base_idx": 137570, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 25, + 26, + 27, + 28, + 29, + 30, + 36, + 37, + 39 + ], + "molecule_smiles": "NC(=O)[C@@H]1CCCN1C(=O)[C@H](Cc1c[nH]cn1)NC(=O)[C@@H]1CCC(=O)N1", + "scaffold_smiles": "O=C1CCC(C(=O)NC(Cc2c[nH]cn2)C(=O)N2CCCC2)N1" + }, + { + "base_idx": 177919, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "Cc1cnc(C(=O)N[C@@H]2CCSc3ccccc32)cn1", + "scaffold_smiles": "O=C(NC1CCSc2ccccc21)c1cnccn1" + }, + { + "base_idx": 623348, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CC1=Nc2ccccc2/C1=C/c1nc(-c2ccc(C)s2)oc1O", + "scaffold_smiles": "C1=Nc2ccccc2C1=Cc1coc(-c2cccs2)n1" + }, + { + "base_idx": 165153, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "Cc1noc(NS(=O)(=O)c2ccc(N3Cc4ccccc4C3=O)cc2)c1C", + "scaffold_smiles": "O=C1c2ccccc2CN1c1ccc(S(=O)(=O)Nc2ccno2)cc1" + }, + { + "base_idx": 112758, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 6, + 7, + 8, + 13, + 14, + 15, + 16, + 17, + 18, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 46, + 47, + 48, + 52, + 53 + ], + "molecule_smiles": "O=C(CCn1c(=S)[nH]c2cc3c(cc2c1=O)OCO3)N1CCN(C2CCCCC2)CC1", + "scaffold_smiles": "O=C(CCn1c(=S)[nH]c2cc3c(cc2c1=O)OCO3)N1CCN(C2CCCCC2)CC1" + }, + { + "base_idx": 529495, + "atom_indices": [ + 7, + 8, + 10, + 13, + 14, + 15, + 16, + 17, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 43, + 44, + 45, + 47, + 48, + 51, + 54, + 57 + ], + "molecule_smiles": "CCOC(=O)N1CCC(N2C[C@@]34C=C[C@@H](O3)[C@H](C(=O)Nc3ccc(OCC)cc3)[C@H]4C2=O)CC1", + "scaffold_smiles": "O=C(Nc1ccccc1)C1C2C=CC3(CN(C4CCNCC4)C(=O)C13)O2" + }, + { + "base_idx": 389481, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 14, + 15, + 16, + 17, + 18, + 26, + 27, + 32, + 35, + 36, + 43, + 46 + ], + "molecule_smiles": "COc1cc2c3c(c1OC)C1(C=CC(=NNC(N)=O)C=C1)C[C@@H]3N(C(C)=O)CC2", + "scaffold_smiles": "N=C1C=CC2(C=C1)CC1NCCc3cccc2c31" + }, + { + "base_idx": 171896, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 27, + 28, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "Cc1nc2sc([C@H](c3cccs3)N3CCN(C(=O)c4ccco4)CC3)c(O)n2n1", + "scaffold_smiles": "O=C(c1ccco1)N1CCN(C(c2cccs2)c2cn3ncnc3s2)CC1" + }, + { + "base_idx": 278005, + "atom_indices": [ + 2, + 3, + 4, + 7, + 20 + ], + "molecule_smiles": "[H]/N=C(\\N)SC[C@H]1CNC(N(C)C)=[S+]1", + "scaffold_smiles": "C1=[S+]CCN1" + }, + { + "base_idx": 692473, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "C=C1C(=O)O[C@@H]2C[C@@]3(C)CCC[C@H](C)C3=C[C@H]12", + "scaffold_smiles": "C=C1C(=O)OC2CC3CCCCC3=CC12" + }, + { + "base_idx": 409284, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 42 + ], + "molecule_smiles": "CO[C@@H]1COCC[C@@H]1N[C@@H]1CC[C@@](C(=O)N2CCc3ncc(C(F)(F)F)cc3C2)(C(C)C)C1", + "scaffold_smiles": "O=C(C1CCC(NC2CCOCC2)C1)N1CCc2ncccc2C1" + }, + { + "base_idx": 321749, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "COc1ccc2c(COC(=O)C[C@@H]3Sc4ccccc4NC3=O)cc(=O)oc2c1", + "scaffold_smiles": "O=C(CC1Sc2ccccc2NC1=O)OCc1cc(=O)oc2ccccc12" + }, + { + "base_idx": 2936, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "c1ccc(CCNc2ccc3nncn3n2)cc1", + "scaffold_smiles": "c1ccc(CCNc2ccc3nncn3n2)cc1" + }, + { + "base_idx": 153124, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "Cn1c(Cn2c(=O)sc3ccccc32)nnc1SCC(N)=O", + "scaffold_smiles": "O=c1sc2ccccc2n1Cc1nnc[nH]1" + }, + { + "base_idx": 37149, + "atom_indices": [ + 8, + 9, + 10, + 11, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 27 + ], + "molecule_smiles": "O=C([O-])CCNC(=O)c1ccc(/N=N/C2=C/C(=C(\\[O-])O)C(=O)C=C2)cc1", + "scaffold_smiles": "C=C1C=C(N=Nc2ccccc2)C=CC1=O" + }, + { + "base_idx": 440036, + "atom_indices": [ + 7, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "COC(=O)C1(C(=O)OC)[S+]=c2ccccc2=C1[O-]", + "scaffold_smiles": "C1=c2ccccc2=[S+]C1" + }, + { + "base_idx": 600954, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 22, + 23, + 24, + 25 + ], + "molecule_smiles": "CC[C@H]1CCCCN1C(=O)c1nn2cccnc2c1Cl", + "scaffold_smiles": "O=C(c1cc2ncccn2n1)N1CCCCC1" + }, + { + "base_idx": 165628, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 13, + 15, + 18, + 20, + 23, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "Cc1noc(C)c1CN(C(=S)N[C@@H]1C[C@H]2CC[C@@H]1C2)c1ccccc1", + "scaffold_smiles": "S=C(NC1CC2CCC1C2)N(Cc1cnoc1)c1ccccc1" + }, + { + "base_idx": 510774, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 17, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)c2c/c(=C(\\[O-])c3cc(C)ccc3O)cnc2=[S+]1", + "scaffold_smiles": "C1=Cc2cc(=Cc3ccccc3)cnc2=[S+]1" + }, + { + "base_idx": 408277, + "atom_indices": [ + 3, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 31 + ], + "molecule_smiles": "CO[C@@H]1[C@H](OC(=O)NC(=O)CCl)CC[C@]2(CO2)[C@H]1[C@@]1(C)O[C@@H]1CC=C(C)C", + "scaffold_smiles": "C1CCC2(CO2)C(C2CO2)C1" + }, + { + "base_idx": 250517, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 21, + 22, + 23 + ], + "molecule_smiles": "Cc1cc(O)c(C=O)c2c1C(=O)Oc1c(CO)c(O)c3c(c1O2)[C@H](O)OC3=O", + "scaffold_smiles": "O=C1Oc2ccc3c(c2Oc2ccccc21)COC3=O" + }, + { + "base_idx": 162569, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 17, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 34, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "Cc1onc(-c2c(Cl)cccc2Cl)c1C(=O)N[C@H]1[C@H]2SC(C)(C)[C@H]3C(=O)O[C@]1([O-])N23", + "scaffold_smiles": "O=C(NC1C2OC(=O)C3CSC1N32)c1conc1-c1ccccc1" + }, + { + "base_idx": 132975, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 28 + ], + "molecule_smiles": "NC(N)=NC1=NC(=O)/C(=C/c2ccccc2-n2cccn2)S1", + "scaffold_smiles": "O=C1N=CSC1=Cc1ccccc1-n1cccn1" + }, + { + "base_idx": 150703, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "Cn1c2c(c(=O)n(C)c1=O)[C@H](c1cccc(Br)c1)[C@@H]1C(=O)c3ccccc3C1=N2", + "scaffold_smiles": "O=C1c2ccccc2C2=Nc3[nH]c(=O)[nH]c(=O)c3C(c3ccccc3)C12" + }, + { + "base_idx": 156929, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 27, + 28, + 29, + 30, + 31, + 32, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "Cn1c(-n2cccc2)c(-c2c(-c3ccccc3)[nH]c3c2c(=O)[nH]c(=O)n3C)c(=O)[nH]c1=O", + "scaffold_smiles": "O=c1[nH]c(-n2cccc2)c(-c2c(-c3ccccc3)[nH]c3[nH]c(=O)[nH]c(=O)c23)c(=O)[nH]1" + }, + { + "base_idx": 3758, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 13, + 14, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "c1cc2c3c(c1)c1c(n3CCNC2)CCCC1", + "scaffold_smiles": "c1cc2c3c(c1)c1c(n3CCNC2)CCCC1" + }, + { + "base_idx": 105560, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 10, + 11, + 12, + 18, + 19, + 20, + 21, + 22, + 23, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 36 + ], + "molecule_smiles": "O=C(CNC(=O)[C@@H]1Cc2ccccc2CN1)Nc1ccc2c(c1)OCO2", + "scaffold_smiles": "O=C(CNC(=O)C1Cc2ccccc2CN1)Nc1ccc2c(c1)OCO2" + }, + { + "base_idx": 656695, + "atom_indices": [ + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25, + 26, + 27, + 28, + 30, + 31, + 32, + 33, + 34, + 35, + 38, + 39, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 49, + 50, + 51 + ], + "molecule_smiles": "CC(C)(O/N=C(\\C(=O)N[C@H]1C(=O)N2C3=C(CS[C@H]12)CN1C=CC=C[C@@H]1OC3=O)c1csc(N)n1)C(=O)O", + "scaffold_smiles": "N=C(C(=O)NC1C(=O)N2C3=C(CSC12)CN1C=CC=CC1OC3=O)c1cscn1" + }, + { + "base_idx": 79123, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 15, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 33, + 34, + 35, + 36, + 37, + 38 + ], + "molecule_smiles": "O=C(/N=C1\\S[C@@H]2CS(=O)(=O)C[C@@H]2N1Cc1ccccc1)C1CCCCC1", + "scaffold_smiles": "O=C(N=C1SC2CS(=O)(=O)CC2N1Cc1ccccc1)C1CCCCC1" + }, + { + "base_idx": 664462, + "atom_indices": [ + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "CC(C)(C)NC(=O)CN(C(=O)c1csnn1)c1cccc2c1CCCC2", + "scaffold_smiles": "O=C(Nc1cccc2c1CCCC2)c1csnn1" + }, + { + "base_idx": 28361, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 24, + 28, + 29, + 30, + 34, + 35, + 36 + ], + "molecule_smiles": "O=C1C=C(NC2=C[C@@H]3O[C@@H](c4ccccc4)OC[C@@H]3OC2=O)CCC1", + "scaffold_smiles": "O=C1C=C(NC2=CC3OC(c4ccccc4)OCC3OC2=O)CCC1" + }, + { + "base_idx": 17210, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 28 + ], + "molecule_smiles": "O=S1(=O)C=C(Sc2nnc(-c3ccccc3)o2)c2ccccc21", + "scaffold_smiles": "O=S1(=O)C=C(Sc2nnc(-c3ccccc3)o2)c2ccccc21" + }, + { + "base_idx": 419011, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "COCCCNC(=O)c1cc2c(s1)CS(=O)(=O)C2", + "scaffold_smiles": "O=S1(=O)Cc2ccsc2C1" + }, + { + "base_idx": 148253, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 24, + 27 + ], + "molecule_smiles": "FC(F)(F)c1cccnc1Oc1ccc(C2=NCCS2)cc1", + "scaffold_smiles": "c1ccc(Oc2ccc(C3=NCCS3)cc2)nc1" + }, + { + "base_idx": 3896, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "O=C(C1=C(Nc2cccc3ccccc23)S[C@H]2C=CC=CN12)c1ccc(OC(F)F)cc1", + "scaffold_smiles": "O=C(C1=C(Nc2cccc3ccccc23)SC2C=CC=CN12)c1ccccc1" + }, + { + "base_idx": 170308, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "Cc1ncsc1CCCNCc1cn[nH]c1-c1cccc2ccccc12", + "scaffold_smiles": "c1ccc2c(-c3[nH]ncc3CNCCCc3cncs3)cccc2c1" + }, + { + "base_idx": 148271, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 30, + 33, + 34, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "FC(F)(F)c1ccc(/C=C/c2nc(COc3ccc(CCCCn4ccnn4)cc3)co2)cc1", + "scaffold_smiles": "C(=Cc1nc(COc2ccc(CCCCn3ccnn3)cc2)co1)c1ccccc1" + }, + { + "base_idx": 433048, + "atom_indices": [ + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 18, + 19, + 20, + 22, + 24 + ], + "molecule_smiles": "COC(=O)N[C@@H](C)N1C(=O)[C@@H]2[C@H](C1=O)[C@H]1C=C[C@@H]2C1", + "scaffold_smiles": "O=C1NC(=O)C2C3C=CC(C3)C12" + }, + { + "base_idx": 18982, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 19, + 21, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 38 + ], + "molecule_smiles": "O=S(=O)(c1ccc(-c2ccon2)s1)N1CCN(c2ccncc2)CC1", + "scaffold_smiles": "O=S(=O)(c1ccc(-c2ccon2)s1)N1CCN(c2ccncc2)CC1" + }, + { + "base_idx": 46061, + "atom_indices": [ + 11, + 12, + 13, + 15, + 16, + 17, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "O=C(O)CCCN1CCC(O[C@@H](c2ccc(Cl)cc2)c2ccccn2)CC1", + "scaffold_smiles": "c1ccc(C(OC2CCNCC2)c2ccccn2)cc1" + }, + { + "base_idx": 694864, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "C=CCn1c(C)cc(C(=O)CN2C(=O)N[C@@]3(CCCc4ccccc43)C2=O)c1C", + "scaffold_smiles": "O=C(CN1C(=O)NC2(CCCc3ccccc32)C1=O)c1cc[nH]c1" + }, + { + "base_idx": 250878, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 43, + 44, + 45, + 46, + 47 + ], + "molecule_smiles": "Cc1cc(OCC(=O)Nc2ccc(CN3CCOCC3)cc2)c2c(C)c(C)c(=O)oc2c1", + "scaffold_smiles": "O=C(COc1cccc2oc(=O)ccc12)Nc1ccc(CN2CCOCC2)cc1" + }, + { + "base_idx": 60968, + "atom_indices": [ + 0, + 1, + 2, + 4, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "O=C(NNS(=O)(=O)c1ccc2ccccc2c1)c1cc(-c2ccccn2)nc2ccccc12", + "scaffold_smiles": "O=C(NNS(=O)(=O)c1ccc2ccccc2c1)c1cc(-c2ccccn2)nc2ccccc12" + }, + { + "base_idx": 652862, + "atom_indices": [ + 11, + 12, + 13, + 14, + 15, + 17, + 28, + 29, + 32, + 33, + 34, + 37, + 38 + ], + "molecule_smiles": "CC(C)CCN1CCN(C2CSCCSC2)C[C@@H]1CCO", + "scaffold_smiles": "C1CN(C2CSCCSC2)CCN1" + }, + { + "base_idx": 29499, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "O=C1/C(=C/c2ccncc2)SC(=S)N1[C@@H]1CCS(=O)(=O)C1", + "scaffold_smiles": "O=C1C(=Cc2ccncc2)SC(=S)N1C1CCS(=O)(=O)C1" + }, + { + "base_idx": 670547, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "CC(=O)OCCn1c(=O)c2cc3c(=O)n(CCOC(C)=O)c(=O)c3cc2c1=O", + "scaffold_smiles": "O=c1[nH]c(=O)c2cc3c(=O)[nH]c(=O)c3cc12" + }, + { + "base_idx": 224090, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 28, + 29, + 30, + 31, + 33 + ], + "molecule_smiles": "Cc1ccc(N2C(SCC(=O)O)=N/C(=N\\c3cccc(C)c3)C23CCCC3)cc1", + "scaffold_smiles": "C1=NC(=Nc2ccccc2)C2(CCCC2)N1c1ccccc1" + }, + { + "base_idx": 408361, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 35, + 36, + 37 + ], + "molecule_smiles": "COc1c2c(nc3occc13)[C@](CC=C(C)C)(OC)C(=O)C=C2", + "scaffold_smiles": "O=C1C=Cc2cc3ccoc3nc2C1" + }, + { + "base_idx": 177198, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "Cc1csc(/C(=N\\O)c2csc(C)n2)n1", + "scaffold_smiles": "N=C(c1cscn1)c1nccs1" + }, + { + "base_idx": 151773, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "Cn1c2ccccc2c2nnc(SCc3ccc4nonc4c3)nc21", + "scaffold_smiles": "c1ccc2c(c1)[nH]c1nc(SCc3ccc4nonc4c3)nnc12" + }, + { + "base_idx": 60712, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 36, + 37, + 38, + 41, + 42, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "O=C(Nc1cc2c(=O)n(-c3ccc(Oc4ccccc4)cc3)ccc2oc1=O)c1ccccc1", + "scaffold_smiles": "O=C(Nc1cc2c(=O)n(-c3ccc(Oc4ccccc4)cc3)ccc2oc1=O)c1ccccc1" + }, + { + "base_idx": 257431, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "Cc1cc(C)n2c(SCCN3C(=O)c4ccccc4C3=O)nnc2n1", + "scaffold_smiles": "O=C1c2ccccc2C(=O)N1CCSc1nnc2ncccn12" + }, + { + "base_idx": 278174, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 21, + 31, + 32, + 33, + 34, + 35, + 43, + 46 + ], + "molecule_smiles": "C[C@H]1c2cccc(O)c2C(=O)[C@@H]2C(=O)[C@]3(O)C(=O)[C@H](C(N)=O)C(=O)[C@@H](N(C)C)[C@@H]3[C@@H](O)[C@@H]21", + "scaffold_smiles": "O=C1CC(=O)C2C(=O)C3C(=O)c4ccccc4CC3CC2C1" + }, + { + "base_idx": 629454, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "CC1(C)CC(=O)C/C(=N\\NC(=O)[C@H]2[C@@H]3CCCC[C@@H]32)C1", + "scaffold_smiles": "O=C1CCCC(=NNC(=O)C2C3CCCCC32)C1" + }, + { + "base_idx": 281421, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CSc1nnc2c3ccccc3c3ccccc3c2n1", + "scaffold_smiles": "c1ccc2c(c1)c1ccccc1c1nncnc21" + }, + { + "base_idx": 422577, + "atom_indices": [ + 5, + 6, + 7, + 11, + 14, + 17, + 18, + 19, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "COC1(OC)N=C(N)[C@@]2(C#N)[C@H](c3ccccc3)NC(C)=C[C@@]12C#N", + "scaffold_smiles": "C1=CC2CN=CC2C(c2ccccc2)N1" + }, + { + "base_idx": 45400, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "O=C(O)CCN1C(=O)c2ccc(Oc3ccc4c(c3)C(=O)N(CCC(=O)O)C4=O)cc2C1=O", + "scaffold_smiles": "O=C1NC(=O)c2cc(Oc3ccc4c(c3)C(=O)NC4=O)ccc21" + }, + { + "base_idx": 667116, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "CC(=O)c1ccc(OC(=O)CCN2c3ccccc3Sc3ccccc32)cc1", + "scaffold_smiles": "O=C(CCN1c2ccccc2Sc2ccccc21)Oc1ccccc1" + }, + { + "base_idx": 647227, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 18 + ], + "molecule_smiles": "CC(C)N1C(=S)SC(C)(C)[C@H]1NO", + "scaffold_smiles": "S=C1NCCS1" + }, + { + "base_idx": 320483, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 23, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "COc1ccc2c(c1)c1nc3n(c(=O)c1n2CC(=O)Nc1cc(C)[nH]n1)CCS3", + "scaffold_smiles": "O=C(Cn1c2ccccc2c2nc3n(c(=O)c21)CCS3)Nc1cc[nH]n1" + }, + { + "base_idx": 240604, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 23, + 26, + 41, + 44, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "CC1=[S+]/C(=C(/[O-])N(CCN2CCCCC2)C[C@H]2CCCN(C3CCCC3)C2)C=C1", + "scaffold_smiles": "C1=CC(=CN(CCN2CCCCC2)CC2CCCN(C3CCCC3)C2)[S+]=C1" + }, + { + "base_idx": 672371, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 48, + 49, + 50 + ], + "molecule_smiles": "CC(=O)O[C@H](c1nc2ccccc2c(=O)n1[C@@H]1C[C@@]2(OC1=O)c1ccccc1N1C(=O)C(C)(C)N(O)[C@H]12)C(C)C", + "scaffold_smiles": "O=C1OC2(CC1n1cnc3ccccc3c1=O)c1ccccc1N1C(=O)CNC12" + }, + { + "base_idx": 151142, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "Cn1cnc(-c2cc3nccc(Oc4ccc(NC(=S)NC(=O)Cc5ccccc5)cc4F)c3s2)c1", + "scaffold_smiles": "O=C(Cc1ccccc1)NC(=S)Nc1ccc(Oc2ccnc3cc(-c4c[nH]cn4)sc23)cc1" + }, + { + "base_idx": 594536, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 49, + 50, + 51, + 52, + 53 + ], + "molecule_smiles": "CCCCC1=CC2=C(c3ccco3)C(=O)[C@@](C)(OC(=O)c3ccc(OC)cc3)C(=O)C2=CN1C1CC1", + "scaffold_smiles": "O=C(OC1C(=O)C2=CN(C3CC3)C=CC2=C(c2ccco2)C1=O)c1ccccc1" + }, + { + "base_idx": 36725, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "O=C(Oc1cc2oc(=O)c3c(c2cc1Cl)CCCC3)c1cccs1", + "scaffold_smiles": "O=C(Oc1ccc2c3c(c(=O)oc2c1)CCCC3)c1cccs1" + }, + { + "base_idx": 383114, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 28, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "COc1ccc(-n2cc(CNCCc3cnc[nH]3)c(-c3cccc(F)c3)n2)cc1", + "scaffold_smiles": "c1ccc(-c2nn(-c3ccccc3)cc2CNCCc2cnc[nH]2)cc1" + }, + { + "base_idx": 623971, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 17, + 20 + ], + "molecule_smiles": "CC1=NN(C(=O)c2ccoc2C)[C@@](O)(C(F)(F)F)C1", + "scaffold_smiles": "O=C(c1ccoc1)N1CCC=N1" + }, + { + "base_idx": 588453, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "CCCCN1c2ccccc2N(CCCO)c2cc(C)nnc21", + "scaffold_smiles": "c1ccc2c(c1)Nc1ccnnc1N2" + }, + { + "base_idx": 432601, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 19, + 20, + 21, + 22, + 23, + 24, + 30, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "COC(=O)c1c(C(=O)OC)c(-c2ccc(F)cc2)n2c1CS(=O)(=O)C2", + "scaffold_smiles": "O=S1(=O)Cc2ccc(-c3ccccc3)n2C1" + }, + { + "base_idx": 24319, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 19, + 20, + 22, + 24 + ], + "molecule_smiles": "O=C1N[C@@H](Cc2cnc[nH]2)C(=O)N2CCC[C@@H]12", + "scaffold_smiles": "O=C1NC(Cc2cnc[nH]2)C(=O)N2CCCC12" + }, + { + "base_idx": 464628, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 43, + 44, + 45, + 46, + 47, + 48 + ], + "molecule_smiles": "CCn1cc(-c2ccnc3[nH]c(-c4cccc(CN(C)C)c4)cc23)c(-c2ccc(NC(=O)N(C)C)cc2)n1", + "scaffold_smiles": "c1ccc(-c2cc3c(-c4c[nH]nc4-c4ccccc4)ccnc3[nH]2)cc1" + }, + { + "base_idx": 443864, + "atom_indices": [ + 4, + 5, + 6, + 7, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "CNC(=O)/C=C1\\NC(C)(C)Cc2c1ccc1ccccc21", + "scaffold_smiles": "C=C1NCCc2c1ccc1ccccc21" + }, + { + "base_idx": 279065, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 18, + 19, + 22, + 25, + 28, + 29, + 30, + 33, + 34, + 36, + 37, + 39, + 40, + 41, + 45, + 46, + 49, + 58 + ], + "molecule_smiles": "C[C@H]1C[C@@H]2[C@H]([C@@H](O)C[C@@]3(C)[C@H]2CC[C@]3(O)C(=O)CO[C@]2([O-])CCC(=O)O2)[C@@]2(C)C=CC(=O)C=C12", + "scaffold_smiles": "O=C1C=CC2C(=C1)CCC1C2CCC2C(C(=O)COC3CCC(=O)O3)CCC21" + }, + { + "base_idx": 158504, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 20 + ], + "molecule_smiles": "Clc1ccccc1CO/N=C1\\CCCc2nonc21", + "scaffold_smiles": "c1ccc(CON=C2CCCc3nonc32)cc1" + }, + { + "base_idx": 456479, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 17, + 18, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ], + "molecule_smiles": "CN(C)S(=O)(=O)c1cccc(-c2nnc(SCc3cc(Cl)cc4c3OCOC4)n2N)c1", + "scaffold_smiles": "c1ccc(-c2nnc(SCc3cccc4c3OCOC4)[nH]2)cc1" + }, + { + "base_idx": 691017, + "atom_indices": [ + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 19, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "C=CCN1C(=O)[C@H](Cc2cccc(Cl)c2)[S+]=C1N=C1C=CC(=C([O-])O)C=C1", + "scaffold_smiles": "C=C1C=CC(=NC2=[S+]C(Cc3ccccc3)C(=O)N2)C=C1" + }, + { + "base_idx": 302119, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "COc1ccccc1/C=C\\C=N\\NC(=O)CCn1nnc2ccccc21", + "scaffold_smiles": "O=C(CCn1nnc2ccccc21)NN=CC=Cc1ccccc1" + }, + { + "base_idx": 273829, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 18, + 19, + 20, + 21, + 22, + 23, + 25, + 26, + 29, + 30, + 31, + 32, + 33, + 35, + 37 + ], + "molecule_smiles": "Cc1c(C(=O)Nc2c(F)cc(F)cc2Br)[s+]c2ncn(CC(=O)N3CCCC3)c([O-])c1-2", + "scaffold_smiles": "O=C(Nc1ccccc1)c1cc2cn(CC(=O)N3CCCC3)cnc-2[s+]1" + }, + { + "base_idx": 626038, + "atom_indices": [ + 2, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 30, + 33, + 37, + 38, + 39, + 40, + 41, + 42, + 51, + 53 + ], + "molecule_smiles": "CC1(C)S[C@@H]2[C@H](NC(=O)[C@H](NC(=O)N3CCN(S(C)(=O)=O)C3=O)c3ccccc3)C(=O)N2[C@H]1C(=O)[O-]", + "scaffold_smiles": "O=C(NC1C(=O)N2CCSC12)C(NC(=O)N1CCNC1=O)c1ccccc1" + }, + { + "base_idx": 468473, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 15, + 18, + 19, + 20, + 21, + 23, + 26, + 27, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 50, + 51, + 59, + 60, + 61 + ], + "molecule_smiles": "CCn1c(C(=O)N2CCC(C(=O)NCCCSC3CCCCC3)CC2)cc2sccc21", + "scaffold_smiles": "O=C(NCCCSC1CCCCC1)C1CCN(C(=O)c2cc3sccc3[nH]2)CC1" + }, + { + "base_idx": 416451, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "COCCN1C[C@H](C(=O)NCc2ccccc2-n2ccnc2C)CCC1=O", + "scaffold_smiles": "O=C1CCC(C(=O)NCc2ccccc2-n2ccnc2)CN1" + }, + { + "base_idx": 364211, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50 + ], + "molecule_smiles": "COc1ccc([C@@H]2c3c(ccc4ccccc34)Oc3ncn4nc(CO/N=C/c5ccccc5)nc4c32)cc1", + "scaffold_smiles": "C(=NOCc1nc2c3c(ncn2n1)Oc1ccc2ccccc2c1C3c1ccccc1)c1ccccc1" + }, + { + "base_idx": 535482, + "atom_indices": [ + 5, + 7, + 8, + 10, + 13, + 14, + 15, + 16, + 17, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "molecule_smiles": "CCO[C@@]1([O-])C[C@@]2(O)C[S+]=C3C(C#N)=C(c4ccc(F)cc4)C(C#N)=C(N1)N32", + "scaffold_smiles": "C1=C(c2ccccc2)C=C2NCCC3C[S+]=C1N23" + }, + { + "base_idx": 174535, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 11, + 13, + 14, + 15, + 17, + 25, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "CC1=N/C(=C(/[O-])NC2CCOCC2)[S+]=C1[C@]1(C)CC(c2ccccc2)=NO1", + "scaffold_smiles": "C1=NC(=CNC2CCOCC2)[S+]=C1C1CC(c2ccccc2)=NO1" + }, + { + "base_idx": 278022, + "atom_indices": [ + 2, + 3, + 4, + 7, + 20 + ], + "molecule_smiles": "CN(C)C1=NC[C@H](C[S+]=C(N)N)S1", + "scaffold_smiles": "C1=NCCS1" + }, + { + "base_idx": 654765, + "atom_indices": [ + 10, + 11, + 17, + 18, + 20 + ], + "molecule_smiles": "CC(C)[C@@H]1N(C(N)=S)N=CC1(C)C", + "scaffold_smiles": "C1=NNCC1" + }, + { + "base_idx": 626139, + "atom_indices": [ + 1, + 2, + 3, + 6, + 9, + 11, + 14, + 17 + ], + "molecule_smiles": "C[C@@]12CC[C@@H](C/C1=N/O)[C@@]2(C)CBr", + "scaffold_smiles": "N=C1CC2CCC1C2" + }, + { + "base_idx": 93467, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23 + ], + "molecule_smiles": "O=C(CSc1n[nH]c(=O)[nH]c1=O)NC1CC1", + "scaffold_smiles": "O=C(CSc1n[nH]c(=O)[nH]c1=O)NC1CC1" + }, + { + "base_idx": 618824, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 13, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 33, + 34, + 35, + 36, + 47, + 49, + 50 + ], + "molecule_smiles": "CC1CCN(CCNC(=O)[C@H]2CCCN(c3ncnc4c3nc3n4CCCCC3)C2)CC1", + "scaffold_smiles": "O=C(NCCN1CCCCC1)C1CCCN(c2ncnc3c2nc2n3CCCCC2)C1" + }, + { + "base_idx": 567885, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 32, + 37 + ], + "molecule_smiles": "CCCn1nc(C(=O)Nc2ccc3nc(C)sc3c2)c2ccccc2c1=O", + "scaffold_smiles": "O=C(Nc1ccc2ncsc2c1)c1n[nH]c(=O)c2ccccc12" + }, + { + "base_idx": 37240, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 7, + 9, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 26, + 27, + 29 + ], + "molecule_smiles": "O=C(Oc1nsnc1N1CCOCC1)N1CCCC1", + "scaffold_smiles": "O=C(Oc1nsnc1N1CCOCC1)N1CCCC1" + }, + { + "base_idx": 692063, + "atom_indices": [ + 5, + 8, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 35, + 36, + 37 + ], + "molecule_smiles": "C=CC[C@@]1(O)CC[C@H]2[C@H]3CCC4=CC(=O)CCC4=C3C=C[C@]21C", + "scaffold_smiles": "O=C1C=C2CCC3C(=C2CC1)C=CC1CCCC13" + }, + { + "base_idx": 630699, + "atom_indices": [ + 4, + 9, + 10, + 11, + 12, + 13, + 14, + 16, + 19, + 20, + 21, + 23, + 24, + 25, + 27, + 28, + 29, + 30, + 31, + 32, + 34, + 35, + 36, + 37, + 40, + 41, + 42, + 45, + 56, + 57 + ], + "molecule_smiles": "CC1(C)CC(=O)C(=CNCCN2CCN(C(=O)[C@]34C[C@H]5C[C@H](C[C@H](C5)C3)C4)CC2)C(=O)C1", + "scaffold_smiles": "O=C1CCCC(=O)C1=CNCCN1CCN(C(=O)C23CC4CC(CC(C4)C2)C3)CC1" + }, + { + "base_idx": 26681, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 29, + 30, + 31, + 32, + 33, + 34, + 44, + 45, + 46, + 47, + 48 + ], + "molecule_smiles": "O=C1CCCc2nc(N3CCN(C(c4ccccc4)c4ccccc4)CC3)ncc21", + "scaffold_smiles": "O=C1CCCc2nc(N3CCN(C(c4ccccc4)c4ccccc4)CC3)ncc21" + }, + { + "base_idx": 626693, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 20, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 34, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "CC1(COc2ccc(C[C@H]3SC(=O)NC3=O)cc2)CCCCC1", + "scaffold_smiles": "O=C1NC(=O)C(Cc2ccc(OCC3CCCCC3)cc2)S1" + }, + { + "base_idx": 295873, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 29 + ], + "molecule_smiles": "COc1ccccc1N1C(=O)N(C(C)=O)[C@@H]2[C@H]1Nc1ccccc1N2C", + "scaffold_smiles": "O=C1NC2Nc3ccccc3NC2N1c1ccccc1" + }, + { + "base_idx": 652358, + "atom_indices": [ + 12, + 13, + 14, + 15, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38 + ], + "molecule_smiles": "CC(C)CCN1C=CN2c3nc4ccccc4nc3N(S(=O)(=O)c3ccccc3)[C@H]12", + "scaffold_smiles": "O=S(=O)(c1ccccc1)N1c2nc3ccccc3nc2N2C=CNC21" + }, + { + "base_idx": 480868, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 23, + 26, + 27, + 30, + 31, + 32, + 33, + 37, + 38, + 39, + 40, + 41, + 42 + ], + "molecule_smiles": "CCN1C=C/C(=C/[C@]23OS(=O)(=O)CCCN2c2ccccc2S3)c2ccccc21", + "scaffold_smiles": "O=S1(=O)CCCN2c3ccccc3SC2(C=C2C=CNc3ccccc32)O1" + }, + { + "base_idx": 1899, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 22 + ], + "molecule_smiles": "c1ccc(Nc2ccc3c4c(cccc24)CC3)cc1", + "scaffold_smiles": "c1ccc(Nc2ccc3c4c(cccc24)CC3)cc1" + }, + { + "base_idx": 176571, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "molecule_smiles": "Cc1cnc(C(=O)OCC(=O)NC(=O)NC(C)C)cn1", + "scaffold_smiles": "c1cnccn1" + }, + { + "base_idx": 248086, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 14, + 15, + 18, + 19, + 20, + 22, + 23, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "Cc1cc2c(OC[C@H](O)CNCc3ccco3)cccc2[nH]1", + "scaffold_smiles": "c1coc(CNCCCOc2cccc3[nH]ccc23)c1" + }, + { + "base_idx": 689703, + "atom_indices": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 23 + ], + "molecule_smiles": "C=CCNC(=O)CSc1ncc2c(n1)-c1c(csc1SC)CC2", + "scaffold_smiles": "c1ncc2c(n1)-c1cscc1CC2" + }, + { + "base_idx": 573140, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11 + ], + "molecule_smiles": "CCCSc1nsc(NC(=O)CCC)n1", + "scaffold_smiles": "c1ncsn1" + }, + { + "base_idx": 625175, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 35, + 38, + 39, + 40, + 42, + 43, + 45, + 48, + 51 + ], + "molecule_smiles": "CC1=CC=CN2C1=NC(=O)C[C@@]2(C)C(=O)N(CC(=O)NC1CCCC1)Cc1ccccc1", + "scaffold_smiles": "O=C1CC(C(=O)N(CC(=O)NC2CCCC2)Cc2ccccc2)N2C=CC=CC2=N1" + }, + { + "base_idx": 583040, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 37, + 38, + 39 + ], + "molecule_smiles": "CCCCn1c(N)c(N(CC)C(=O)c2ccc3c(c2)S(=O)(=O)c2ccccc2C3=O)c(=O)[nH]c1=O", + "scaffold_smiles": "O=C(Nc1c[nH]c(=O)[nH]c1=O)c1ccc2c(c1)S(=O)(=O)c1ccccc1C2=O" + }, + { + "base_idx": 671821, + "atom_indices": [ + 4, + 5, + 6, + 8, + 13, + 16, + 17, + 18, + 19, + 30, + 31, + 32, + 35, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 56, + 57 + ], + "molecule_smiles": "CC(=O)O[C@@H]1C[C@@H]2C(C)(C)C(=O)CC[C@@]2(C)[C@@H]2CC[C@@]3(C)[C@H](c4ccoc4)OC(=O)[C@H]4O[C@]43[C@@]12C", + "scaffold_smiles": "O=C1CCC2C(CCC3C2CCC2C(c4ccoc4)OC(=O)C4OC423)C1" + }, + { + "base_idx": 390261, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 29, + 30, + 31, + 32, + 33, + 34, + 41, + 44, + 45 + ], + "molecule_smiles": "COc1cc2c(cc1OC)[C@H]1Cc3c(cnc4c(-c5ccc(F)cc5)cnn34)C(=O)N1CC2", + "scaffold_smiles": "O=C1c2cnc3c(-c4ccccc4)cnn3c2CC2c3ccccc3CCN12" + }, + { + "base_idx": 602126, + "atom_indices": [ + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 36, + 37 + ], + "molecule_smiles": "CC[C@]1(C)Cc2c([s+]c3nnn(Cc4ccccc4Cl)c([O-])c2-3)CO1", + "scaffold_smiles": "c1ccc(Cn2cc3c4c([s+]c-3nn2)COCC4)cc1" + }, + { + "base_idx": 380293, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 25, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "COc1ccc(/C=c2/[s+]c3cc/c(=C(\\[O-])N4CCc5ccccc5C4)cc3[nH]c2=O)cc1", + "scaffold_smiles": "O=c1[nH]c2cc(=CN3CCc4ccccc4C3)ccc2[s+]c1=Cc1ccccc1" + }, + { + "base_idx": 701825, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 29, + 30, + 38, + 39, + 40, + 41, + 42, + 43 + ], + "molecule_smiles": "C(=N/N1CCN(C2c3ccccc3-c3ccccc32)CC1)\\c1cccnc1", + "scaffold_smiles": "C(=NN1CCN(C2c3ccccc3-c3ccccc32)CC1)c1cccnc1" + }, + { + "base_idx": 567352, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "CCCn1c2ccccc2c2cnn(CC(=O)NCCCOC)c(=O)c21", + "scaffold_smiles": "O=c1[nH]ncc2c1[nH]c1ccccc12" + }, + { + "base_idx": 288302, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 21, + 30, + 31 + ], + "molecule_smiles": "COc1ncnc2c1oc1nc(CC(C)C)c3c(c12)CC(C)(C)OC3", + "scaffold_smiles": "c1ncc2oc3ncc4c(c3c2n1)CCOC4" + }, + { + "base_idx": 120112, + "atom_indices": [ + 0, + 1, + 2, + 4, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 42, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57 + ], + "molecule_smiles": "O=C(CCC(=O)N1CCOc2ccc(Cl)cc21)NCCCN1CCC(Cc2ccccc2)CC1", + "scaffold_smiles": "O=C(CCC(=O)N1CCOc2ccccc21)NCCCN1CCC(Cc2ccccc2)CC1" + }, + { + "base_idx": 199926, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 21, + 22 + ], + "molecule_smiles": "Cc1ccc2nc(N)[nH]c(=O)c2c1[S+]=c1cc[nH]cc1", + "scaffold_smiles": "O=c1[nH]cnc2cccc([S+]=c3cc[nH]cc3)c12" + }, + { + "base_idx": 526551, + "atom_indices": [ + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 30, + 31, + 32, + 33, + 34, + 35, + 40, + 42 + ], + "molecule_smiles": "CCOC(=O)N1CCN(C(=O)CSc2ccc3nnc(-c4cccnc4)n3n2)CC1", + "scaffold_smiles": "O=C(CSc1ccc2nnc(-c3cccnc3)n2n1)N1CCNCC1" + }, + { + "base_idx": 401015, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 14, + 15, + 16, + 17, + 31, + 32, + 33, + 34, + 35, + 36, + 42, + 44, + 53, + 54, + 55 + ], + "molecule_smiles": "COc1cc([C@H]2C(C#N)=C(N)OC3=C2C(=O)CC(C)(C)C3)ccc1OCCN1CCOCC1", + "scaffold_smiles": "O=C1CCCC2=C1C(c1ccc(OCCN3CCOCC3)cc1)C=CO2" + }, + { + "base_idx": 61412, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 27, + 28, + 29, + 30, + 31, + 32, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 43, + 44, + 45, + 47, + 49 + ], + "molecule_smiles": "O=C(Nc1ccc(-c2nc3ccccc3o2)cc1)c1ccc(N2C(=O)[C@@H]3[C@H](C2=O)[C@H]2C=C[C@@H]3C2)cc1", + "scaffold_smiles": "O=C(Nc1ccc(-c2nc3ccccc3o2)cc1)c1ccc(N2C(=O)C3C4C=CC(C4)C3C2=O)cc1" + }, + { + "base_idx": 40505, + "atom_indices": [ + 0, + 1, + 2, + 3, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 43, + 44 + ], + "molecule_smiles": "O=C(OCC(=O)N1CCCc2ccccc21)C1CCN(S(=O)(=O)c2cccs2)CC1", + "scaffold_smiles": "O=C(OCC(=O)N1CCCc2ccccc21)C1CCN(S(=O)(=O)c2cccs2)CC1" + }, + { + "base_idx": 12524, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "O=c1cc(CO)oc([C@@H](Nc2ccccn2)c2ccsc2)c1O", + "scaffold_smiles": "O=c1ccoc(C(Nc2ccccn2)c2ccsc2)c1" + }, + { + "base_idx": 607653, + "atom_indices": [ + 17, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 28, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "CC[C@@H](C)CNCC(=O)N1CCc2sccc2[C@@H]1COc1cccc(C)c1", + "scaffold_smiles": "c1ccc(OCC2NCCc3sccc32)cc1" + }, + { + "base_idx": 90087, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 27, + 30, + 31, + 32, + 33, + 34, + 35, + 37, + 38, + 39, + 40, + 41, + 42, + 54 + ], + "molecule_smiles": "O=C(CSc1nnc(NC2CCCCC2)s1)Nc1ccc2c(c1)C(=O)c1ccccc1C2=O", + "scaffold_smiles": "O=C(CSc1nnc(NC2CCCCC2)s1)Nc1ccc2c(c1)C(=O)c1ccccc1C2=O" + }, + { + "base_idx": 207685, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 27, + 28, + 29, + 30, + 31, + 32, + 40 + ], + "molecule_smiles": "Cc1ccc(S(=O)(=O)NC(=O)c2ccc(-c3ccc(-c4ccccc4)cc3)o2)c(C)c1", + "scaffold_smiles": "O=C(NS(=O)(=O)c1ccccc1)c1ccc(-c2ccc(-c3ccccc3)cc2)o1" + }, + { + "base_idx": 627264, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 20, + 21, + 22, + 23, + 24, + 28, + 29, + 32, + 34 + ], + "molecule_smiles": "CC1(c2ccc3noc(-c4ccc(F)cc4)c3c2)OCCO1", + "scaffold_smiles": "c1ccc(-c2onc3ccc(C4OCCO4)cc23)cc1" + }, + { + "base_idx": 444941, + "atom_indices": [ + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 21, + 23 + ], + "molecule_smiles": "CN1CCN(c2ncnc3c2cnn3C)CC1", + "scaffold_smiles": "c1nc(N2CCNCC2)c2cn[nH]c2n1" + }, + { + "base_idx": 630599, + "atom_indices": [ + 1, + 2, + 3, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "C[C@@]1(C(=O)NNC(=O)c2ccc(=O)oc2)C[C@@H]1Br", + "scaffold_smiles": "O=C(NNC(=O)C1CC1)c1ccc(=O)oc1" + }, + { + "base_idx": 256967, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 29 + ], + "molecule_smiles": "Cc1cc(C=O)c2c(c1)C=C[C@]1(Oc3ccccc3C(=O)N1C)O2", + "scaffold_smiles": "O=C1NC2(C=Cc3ccccc3O2)Oc2ccccc21" + }, + { + "base_idx": 11259, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 25, + 26, + 27, + 28, + 29, + 30, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "O[C@@H](COc1ccc2ccccc2c1Br)Cn1cnc(-c2ccccc2)c1", + "scaffold_smiles": "c1ccc(-c2cn(CCCOc3ccc4ccccc4c3)cn2)cc1" + }, + { + "base_idx": 447034, + "atom_indices": [ + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 25, + 26, + 32, + 34 + ], + "molecule_smiles": "CN1CCN(/C=N/C2=NN(c3cccc(C(F)(F)F)c3)CC2)CC1", + "scaffold_smiles": "C(=NC1=NN(c2ccccc2)CC1)N1CCNCC1" + }, + { + "base_idx": 63162, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "O=C(NCc1cccs1)c1cccn2c(=O)c3sccc3nc12", + "scaffold_smiles": "O=C(NCc1cccs1)c1cccn2c(=O)c3sccc3nc12" + }, + { + "base_idx": 266560, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "molecule_smiles": "Cc1cc(=S)[nH]c(=S)[nH]1", + "scaffold_smiles": "S=c1cc[nH]c(=S)[nH]1" + }, + { + "base_idx": 171825, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 20, + 23, + 24, + 25, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 43, + 44 + ], + "molecule_smiles": "Cc1nc2n(c(=O)c1CCN1CCC(c3noc4cc(F)ccc34)CC1)CCCC2", + "scaffold_smiles": "O=c1c(CCN2CCC(c3noc4ccccc34)CC2)cnc2n1CCCC2" + }, + { + "base_idx": 275011, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 15, + 16, + 18, + 19, + 20, + 21, + 22, + 24 + ], + "molecule_smiles": "Cc1[nH]c(C)c(C(=O)O)c1CN[C@H]1CCCC[C@@H]1C", + "scaffold_smiles": "c1cc(CNC2CCCCC2)c[nH]1" + }, + { + "base_idx": 678585, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16 + ], + "molecule_smiles": "CC(=O)N1c2ccccc2[C@]2(C)[C@H](C1(C)C)C2(Cl)Cl", + "scaffold_smiles": "c1ccc2c(c1)NCC1CC21" + }, + { + "base_idx": 119249, + "atom_indices": [ + 0, + 1, + 2, + 3, + 5, + 7, + 8, + 10, + 16, + 18, + 19, + 22, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "O=C(CCCCCN1C(=O)c2cccc3cccc(c23)C1=O)N1CCOCC1", + "scaffold_smiles": "O=C(CCCCCN1C(=O)c2cccc3cccc(c23)C1=O)N1CCOCC1" + }, + { + "base_idx": 640881, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 26, + 27, + 28, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40 + ], + "molecule_smiles": "CC(C)c1ccc(S(=O)(=O)c2cn(Cc3ccc(F)cc3)c3cc4c(cc3c2=O)OCO4)cc1", + "scaffold_smiles": "O=c1c(S(=O)(=O)c2ccccc2)cn(Cc2ccccc2)c2cc3c(cc12)OCO3" + }, + { + "base_idx": 694646, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "C=C(C(=O)c1ccccc1)[C@H](c1ccccc1)[C@@H](C)C(=O)c1ccccc1", + "scaffold_smiles": "C=C(C(=O)c1ccccc1)C(CC(=O)c1ccccc1)c1ccccc1" + }, + { + "base_idx": 626784, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18 + ], + "molecule_smiles": "CC1(C)NC(=O)N(CN2C(=O)NC(C)(C)C2=O)C1=O", + "scaffold_smiles": "O=C1CNC(=O)N1CN1C(=O)CNC1=O" + }, + { + "base_idx": 247825, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 17, + 20, + 21, + 22, + 23, + 28, + 29, + 30, + 43 + ], + "molecule_smiles": "Cc1ccc(-c2ccccc2[C@@H]2C[C@H]3CN(C)C(=O)[C@]34CCCN24)o1", + "scaffold_smiles": "O=C1NCC2CC(c3ccccc3-c3ccco3)N3CCCC123" + }, + { + "base_idx": 176328, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "molecule_smiles": "Cc1csc2nc(CNS(=O)(=O)N(C)C)cn12", + "scaffold_smiles": "c1cn2ccsc2n1" + }, + { + "base_idx": 36129, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 20, + 21, + 22, + 23, + 24, + 25, + 37, + 38, + 39, + 40, + 41, + 42, + 45, + 46, + 47, + 48, + 49 + ], + "molecule_smiles": "O=C(c1ccc(-n2cnnn2)cc1)N(C1CCCCC1)C1CCCCC1", + "scaffold_smiles": "O=C(c1ccc(-n2cnnn2)cc1)N(C1CCCCC1)C1CCCCC1" + }, + { + "base_idx": 230208, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "Cc1ccc([C@H]2N/C(=N/Cc3ccccc3)Nc3nc(C)cc(=O)n32)cc1", + "scaffold_smiles": "O=c1ccnc2n1C(c1ccccc1)NC(=NCc1ccccc1)N2" + }, + { + "base_idx": 695767, + "atom_indices": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 21 + ], + "molecule_smiles": "C=CCNC1=[S+]/C(=[S+]\\c2ncccc2N([O-])[O-])N=N1", + "scaffold_smiles": "C1=[S+]C(=[S+]c2ccccn2)N=N1" + }, + { + "base_idx": 7708, + "atom_indices": [ + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 25, + 27, + 30 + ], + "molecule_smiles": "O[C@H]1[C@H]2CC[C@@H]1[C@H](N1CCCCC1)CC2", + "scaffold_smiles": "C1CCN(C2CCC3CCC2C3)CC1" + }, + { + "base_idx": 177240, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 28, + 29, + 30, + 32, + 35, + 36, + 37 + ], + "molecule_smiles": "Cc1coc2c(O)cc3c(c12)O[C@]1(N2CCCCC2)C(=O)NCCC[C@@H]31", + "scaffold_smiles": "O=C1NCCCC2c3ccc4occc4c3OC12N1CCCCC1" + }, + { + "base_idx": 246078, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 31, + 32, + 33, + 36 + ], + "molecule_smiles": "Cc1ccc(-c2csc3ncnc(Sc4nnc(CN5CCCCCC5=O)o4)c23)cc1", + "scaffold_smiles": "O=C1CCCCCN1Cc1nnc(Sc2ncnc3scc(-c4ccccc4)c23)o1" + }, + { + "base_idx": 440721, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 39, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "COC(=O)/C(=N/Nc1ccccc1Cl)Sc1nnc([C@H](O)c2ccccc2)n1-c1ccccc1", + "scaffold_smiles": "C(=NNc1ccccc1)Sc1nnc(Cc2ccccc2)n1-c1ccccc1" + }, + { + "base_idx": 693077, + "atom_indices": [ + 7, + 8, + 9, + 12, + 13, + 16, + 18, + 23, + 24, + 25, + 28 + ], + "molecule_smiles": "C=C/C(C)=C/C[C@H]1C(=C)CC[C@H]2[C@]1(C)CCC[C@@]2(C)C(=O)O", + "scaffold_smiles": "C=C1CCC2CCCCC2C1" + }, + { + "base_idx": 648338, + "atom_indices": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 29, + 32, + 34 + ], + "molecule_smiles": "CC(C)COC(=O)N1CCC[C@@H]1C(=O)Nc1nccs1", + "scaffold_smiles": "O=C(Nc1nccs1)C1CCCN1" + }, + { + "base_idx": 241664, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25, + 26, + 27 + ], + "molecule_smiles": "Cc1ccc(/C=N/OC(=O)c2c(C)noc2-c2snnc2C)cc1", + "scaffold_smiles": "O=C(ON=Cc1ccccc1)c1cnoc1-c1cnns1" + }, + { + "base_idx": 596028, + "atom_indices": [ + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ], + "molecule_smiles": "CCCC[C@@H](CC)CNC(=O)CCCCn1c(=O)[nH]c2ccsc2c1=O", + "scaffold_smiles": "O=c1[nH]c(=O)c2sccc2[nH]1" + }, + { + "base_idx": 141271, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 18, + 24 + ], + "molecule_smiles": "Nc1nc(=O)[nH]c([C@]23C[C@H]4C[C@H](C[C@H](C4)C2)C3)n1", + "scaffold_smiles": "O=c1ncnc(C23CC4CC(CC(C4)C2)C3)[nH]1" + }, + { + "base_idx": 216088, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 29 + ], + "molecule_smiles": "Cc1ccc(NC(=S)/N=N\\c2c(O)[nH]c3ccccc23)cc1", + "scaffold_smiles": "S=C(N=Nc1c[nH]c2ccccc12)Nc1ccccc1" + }, + { + "base_idx": 546479, + "atom_indices": [ + 14, + 15, + 16, + 17, + 18, + 19, + 28, + 29, + 30, + 31, + 32, + 33, + 34 + ], + "molecule_smiles": "CCOC(=O)[C@@H](C)Sc1nnc2n1C(C)(C)Cc1ccccc1-2", + "scaffold_smiles": "c1ccc2c(c1)CCn1cnnc1-2" + }, + { + "base_idx": 162888, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11 + ], + "molecule_smiles": "Cc1oc2c(c1C(=O)NCCCC(=O)O)CCCC2", + "scaffold_smiles": "c1cc2c(o1)CCCC2" + }, + { + "base_idx": 157629, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 17, + 18, + 22 + ], + "molecule_smiles": "Clc1cccc2c1CN(C1=NCCN1)C2", + "scaffold_smiles": "c1ccc2c(c1)CN(C1=NCCN1)C2" + }, + { + "base_idx": 71, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 14 + ], + "molecule_smiles": "c1n[nH]c(NC2=NCCC2)n1", + "scaffold_smiles": "c1n[nH]c(NC2=NCCC2)n1" + }, + { + "base_idx": 262486, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "Cc1cc(C)c(O[C@@]2(c3ccccc3)OC(=O)c3ccccc32)c(C)c1", + "scaffold_smiles": "O=C1OC(Oc2ccccc2)(c2ccccc2)c2ccccc21" + }, + { + "base_idx": 165806, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 19, + 20 + ], + "molecule_smiles": "Cc1nnc2[s+]c(-c3cccc(F)c3)nn2c1[O-]", + "scaffold_smiles": "c1ccc(-c2nn3ccnnc3[s+]2)cc1" + }, + { + "base_idx": 552575, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 22, + 23, + 27, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCN1C=C(Cc2cccs2)C2=C(C1)[C@H](c1cccs1)C(C#N)=C(N)O2", + "scaffold_smiles": "C1=CC(c2cccs2)C2=C(O1)C(Cc1cccs1)=CNC2" + }, + { + "base_idx": 228424, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 21, + 22, + 24, + 25, + 26, + 27 + ], + "molecule_smiles": "Cc1ccc(N(C(=O)c2ccc(Br)o2)[C@H]2C=CS(=O)(=O)C2)cc1", + "scaffold_smiles": "O=C(c1ccco1)N(c1ccccc1)C1C=CS(=O)(=O)C1" + }, + { + "base_idx": 523989, + "atom_indices": [ + 7, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 31, + 32, + 33, + 34, + 35, + 36 + ], + "molecule_smiles": "CCOC(=O)[C@@H]1[C@H]2COc3cc(OC)ccc3[C@H]2N2C(=O)N(c3ccc(C)cc3)C(=O)[C@@]12C", + "scaffold_smiles": "O=C1C2CC3COc4ccccc4C3N2C(=O)N1c1ccccc1" + }, + { + "base_idx": 542188, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 14, + 15, + 16, + 17, + 18, + 19, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)NC(SCc2cc(=O)[nH][nH]2)=C(C#N)[C@H]1c1cccs1", + "scaffold_smiles": "O=c1cc(CSC2=CC(c3cccs3)C=CN2)[nH][nH]1" + }, + { + "base_idx": 29010, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 38, + 39, + 41, + 42, + 43, + 44, + 45, + 46 + ], + "molecule_smiles": "O=C1/C(=C\\c2c(NCCOCCO)nc3ccccn3c2=O)SC(=S)N1Cc1ccco1", + "scaffold_smiles": "O=C1C(=Cc2cnc3ccccn3c2=O)SC(=S)N1Cc1ccco1" + }, + { + "base_idx": 655948, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 11, + 12, + 13, + 14 + ], + "molecule_smiles": "CC(C)=C1[S+]=C(N2CCCCC2)N=C1[O-]", + "scaffold_smiles": "C=C1C=NC(N2CCCCC2)=[S+]1" + }, + { + "base_idx": 133073, + "atom_indices": [ + 4, + 5, + 6, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 29, + 30, + 32, + 33 + ], + "molecule_smiles": "NC(=S)N/N=C1\\C[C@H](n2nnn(-c3cccc(Cl)c3)c2=S)[C@@H]2CO[C@H]1O2", + "scaffold_smiles": "N=C1CC(n2nnn(-c3ccccc3)c2=S)C2COC1O2" + }, + { + "base_idx": 684831, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 13, + 14, + 15, + 16, + 17, + 25, + 26 + ], + "molecule_smiles": "C=CCn1c(C)cc(C2=NN/C(=N\\CC)SC2)c1C", + "scaffold_smiles": "N=C1NN=C(c2cc[nH]c2)CS1" + }, + { + "base_idx": 606759, + "atom_indices": [ + 8, + 9, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25, + 26 + ], + "molecule_smiles": "CC[C@@H](C)NC(=O)C1=C(C)C(=O)O[C@]12CC[C@@H](C)CC2", + "scaffold_smiles": "O=C1C=CC2(CCCCC2)O1" + }, + { + "base_idx": 623836, + "atom_indices": [ + 4, + 6, + 7, + 12, + 13, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "molecule_smiles": "C[C@@H]1[C@@H](c2ccccc2)N(C)CC[C@@]12NC(=O)NC2=O", + "scaffold_smiles": "O=C1NC(=O)C2(CCNC(c3ccccc3)C2)N1" + }, + { + "base_idx": 382798, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19 + ], + "molecule_smiles": "COc1ccc(-n2c(C)c(C(C)=O)c3cc(OC(C)=O)ccc32)cc1", + "scaffold_smiles": "c1ccc(-n2ccc3ccccc32)cc1" + }, + { + "base_idx": 2574, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31 + ], + "molecule_smiles": "c1ccc(CN2c3ccccc3-c3nc4ccccc4n3[C@@H]2c2ccccn2)cc1", + "scaffold_smiles": "c1ccc(CN2c3ccccc3-c3nc4ccccc4n3C2c2ccccn2)cc1" + }, + { + "base_idx": 93881, + "atom_indices": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 15, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 39, + 41, + 42, + 44, + 50, + 51, + 53, + 55, + 56, + 58 + ], + "molecule_smiles": "O=C(CSc1nc2ccc(Nc3nc(N4CCOCC4)nc(N4CCOCC4)n3)cc2s1)NCc1ccco1", + "scaffold_smiles": "O=C(CSc1nc2ccc(Nc3nc(N4CCOCC4)nc(N4CCOCC4)n3)cc2s1)NCc1ccco1" + }, + { + "base_idx": 638444, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "CC(C)n1c(Sc2nccn2C)nc2c1c(=O)n(C)c(=O)n2C", + "scaffold_smiles": "O=c1[nH]c(=O)c2[nH]c(Sc3ncc[nH]3)nc2[nH]1" + }, + { + "base_idx": 187311, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20 + ], + "molecule_smiles": "Cc1cccc(Oc2ncnc3onc(C)c23)c1C", + "scaffold_smiles": "c1ccc(Oc2ncnc3oncc23)cc1" + }, + { + "base_idx": 99814, + "atom_indices": [ + 0, + 1, + 2, + 4, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45 + ], + "molecule_smiles": "O=C(COC(=O)c1oc2ccccc2c1COc1ccccc1)NCCN1C(=O)C[S+]=C1[O-]", + "scaffold_smiles": "O=C(COC(=O)c1oc2ccccc2c1COc1ccccc1)NCCN1C=[S+]CC1=O" + }, + { + "base_idx": 522425, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35 + ], + "molecule_smiles": "CCOC(=O)c1c(-c2ccccc2)csc1N1C(=O)Nc2ccccc2N1O", + "scaffold_smiles": "O=C1Nc2ccccc2NN1c1cc(-c2ccccc2)cs1" + }, + { + "base_idx": 528940, + "atom_indices": [ + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 29, + 32 + ], + "molecule_smiles": "CCOC(=O)N1CCC(=NNc2nc(C)cc(=O)[nH]2)CC1", + "scaffold_smiles": "O=c1ccnc(NN=C2CCNCC2)[nH]1" + }, + { + "base_idx": 199538, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 35, + 38, + 41 + ], + "molecule_smiles": "Cc1cccc(-c2nn3c(c2-c2ccnc4ccc(C(N)=O)cc24)CCC3)n1", + "scaffold_smiles": "c1ccc(-c2nn3c(c2-c2ccnc4ccccc24)CCC3)nc1" + }, + { + "base_idx": 142006, + "atom_indices": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "N#Cc1c(C(F)(F)F)cc(-c2cccs2)nc1SCc1ccc(Cl)nc1", + "scaffold_smiles": "c1cncc(CSc2cccc(-c3cccs3)n2)c1" + }, + { + "base_idx": 170113, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 17, + 18, + 19, + 20, + 21 + ], + "molecule_smiles": "Cc1nn(C)c(C)c1P(=S)(c1c(C)nn(C)c1C)N(C)C", + "scaffold_smiles": "S=[PH](c1cn[nH]c1)c1cn[nH]c1" + }, + { + "base_idx": 191383, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 15 + ], + "molecule_smiles": "Cc1cccc(N)c1S[C@H]1CCCC[C@@H]1O", + "scaffold_smiles": "c1ccc(SC2CCCCC2)cc1" + }, + { + "base_idx": 5853, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24 + ], + "molecule_smiles": "O=C1OC(c2ccc(Br)cc2)=N/C1=C/N=c1\\ccc(Br)c[nH]1", + "scaffold_smiles": "O=C1OC(c2ccccc2)=NC1=CN=c1cccc[nH]1" + }, + { + "base_idx": 541119, + "atom_indices": [ + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)Nc2ccccc2S1", + "scaffold_smiles": "C1=CSc2ccccc2N1" + }, + { + "base_idx": 29033, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 13, + 14, + 17, + 20, + 22, + 25 + ], + "molecule_smiles": "O=C1/C(=C/c2ccsc2)N2CCC1CC2", + "scaffold_smiles": "O=C1C(=Cc2ccsc2)N2CCC1CC2" + }, + { + "base_idx": 602496, + "atom_indices": [ + 7, + 12, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 27, + 29, + 30, + 34, + 35, + 36, + 38, + 39, + 40, + 41, + 42, + 43, + 44 + ], + "molecule_smiles": "CC[C@@]1(C)C[C@@](CCNCc2ccc(N(C)C)cc2)(c2ccc(F)cc2)CCO1", + "scaffold_smiles": "c1ccc(CNCCC2(c3ccccc3)CCOCC2)cc1" + }, + { + "base_idx": 660646, + "atom_indices": [ + 6, + 7, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 19, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "CC(C)(C)Nc1nc(-c2ccc3c(c2)CCN3C(=O)C2CC2)cs1", + "scaffold_smiles": "O=C(C1CC1)N1CCc2cc(-c3cscn3)ccc21" + }, + { + "base_idx": 641561, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 16, + 17, + 18, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29 + ], + "molecule_smiles": "CC(C)c1ccc(C2=C[S+]=C(NC(=O)c3cccs3)/C2=C(/[O-])O)cc1", + "scaffold_smiles": "C=C1C(c2ccccc2)=C[S+]=C1NC(=O)c1cccs1" + }, + { + "base_idx": 519452, + "atom_indices": [ + 7, + 8, + 21, + 22, + 23, + 28 + ], + "molecule_smiles": "CCOC(=O)C1=C(C)/C(=C(/C)[O-])[S+]=C1NC(=O)COC", + "scaffold_smiles": "C=C1C=CC=[S+]1" + }, + { + "base_idx": 552114, + "atom_indices": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 26 + ], + "molecule_smiles": "CCN1C(=O)c2ccccc2[S+]=c2cc/c(=C(\\[O-])O)cc21", + "scaffold_smiles": "C=c1ccc2c(c1)NC(=O)c1ccccc1[S+]=2" + }, + { + "base_idx": 129499, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 25, + 28, + 31, + 32, + 34, + 35, + 36, + 37, + 38, + 39 + ], + "molecule_smiles": "Nc1nc(SCC(=O)N2N=C3/C(=C/c4ccco4)CCC[C@@H]3[C@@H]2c2ccco2)n[nH]1", + "scaffold_smiles": "O=C(CSc1nc[nH]n1)N1N=C2C(=Cc3ccco3)CCCC2C1c1ccco1" + }, + { + "base_idx": 16258, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 16, + 17, + 19 + ], + "molecule_smiles": "O=S1(=O)N[C@H]2CCCN2c2ccccc21", + "scaffold_smiles": "O=S1(=O)NC2CCCN2c2ccccc21" + }, + { + "base_idx": 249721, + "atom_indices": [ + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 9, + 10, + 11, + 12, + 13, + 23 + ], + "molecule_smiles": "Cc1cc(Sc2nc(N(C)C)nc(N(C)C)n2)nc(Cl)n1", + "scaffold_smiles": "c1cc(Sc2ncncn2)ncn1" + }, + { + "base_idx": 500211, + "atom_indices": [ + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 24, + 25, + 26 + ], + "molecule_smiles": "CCOc1ccc([C@@H]2C=C(c3ccc(C)cc3)Nc3nnnn32)cc1", + "scaffold_smiles": "C1=C(c2ccccc2)Nc2nnnn2C1c1ccccc1" + }, + { + "base_idx": 12155, + "atom_indices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28 + ], + "molecule_smiles": "O=c1c2ccccc2cnn1Cc1n[nH]c(=S)n1-c1cc(Cl)cc(Cl)c1", + "scaffold_smiles": "O=c1c2ccccc2cnn1Cc1n[nH]c(=S)n1-c1ccccc1" + } + ] +} diff --git a/geom/scaffold_murcko_substituent_lora_r16/panels/verification.json b/geom/scaffold_murcko_substituent_lora_r16/panels/verification.json new file mode 100644 index 0000000000000000000000000000000000000000..679be1b8b1ffdf8ebe2303b5e8de3122c4bf9829 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/panels/verification.json @@ -0,0 +1,10 @@ +{ + "panel": "val_murcko_512_seed42.json", + "samples": 4096, + "scaffolds": 512, + "samples_per_scaffold": 8, + "two_rank_disjoint_coverage": true, + "reference_roundtrip_checks": 16, + "all_reference_retention_rmsd_recovery_checks_passed": true, + "model_generation_run": false +} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/README.md b/geom/scaffold_murcko_substituent_lora_r16/runtime/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bbfab1667b743d4b79efe6401c1d1cda8f7900a7 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/README.md @@ -0,0 +1,202 @@ +# Morph + +Flow-matching molecular generation on QM9, GEOM-Drugs, and ChEMBL3D, including +scaffold-decoration fine-tuning. A molecular diffusion transformer (DiT) +predicts clean coordinates, atom and bond types, charges, and edit posteriors. +The sampler combines those predictions with time schedules to move, insert, +delete, and substitute atoms and bonds. + +## Pretrained models + +[Checkpoints on Hugging Face](https://huggingface.co/maltefranke/morph) +include QM9 with explicit hydrogens (epochs 1,000 and 1,500), GEOM-Drugs with +explicit hydrogens (epochs 50 and 100), and ChEMBL3D with explicit hydrogens +(epochs 200, 300, 400) or implicit hydrogens (epochs 100, 200, 300). Download and generate without preparing a dataset: + +```bash +uv sync --locked +source .venv/bin/activate +hf auth login +morph models list +morph models download chembl3d-implicit-h --epoch 300 +# Run generation on a compute worker: +morph generate --model chembl3d-implicit-h --epoch 300 \ + --device cuda --batch 64 --batch-size 16 --output molecules.sdf +``` + +Python loading is equally direct: + +```python +from morph.inference import load_pretrained + +model = load_pretrained("chembl3d-implicit-h", epoch=300, device="cuda") +``` + +The loader fetches the selected weights, config, vocabularies and distributions +and constructs only one network (EMA by default; `use_ema=False` selects current +weights). `morph models list` discovers shortcuts from the Hub's `catalog.json`; +`--offline` reads the cached catalog. Adding a release requires updating that +manifest, with each entry pointing to its model release. You can also pass any +Hugging Face `owner/repo` with `subfolder` and `revision`, or load a local bundle +with `load_pretrained(weights_dir="/path/to/bundle")`. See [inference](docs/inference.md) for sampling, +offline use, checkpoint selection and adding new model releases. + +## Reproducibility + +Each model has a **training bundle** with its original training configuration, +launch settings, frozen source, dependency versions and data references. +The bundles live alongside the corresponding model files on Hugging Face: + +| Model | Configuration and source | +| --- | --- | +| QM9, explicit H | [Training bundle](https://huggingface.co/maltefranke/morph/resolve/main/qm9/explicit_h/training_bundle.tar.gz) | +| GEOM-Drugs, explicit H | [Training bundle](https://huggingface.co/maltefranke/morph/resolve/main/geom/explicit_h/training_bundle.tar.gz) | +| ChEMBL3D, explicit H | [Training bundle](https://huggingface.co/maltefranke/morph/resolve/main/chembl3d/explicit_h/training_bundle.tar.gz) | +| ChEMBL3D, implicit H | [Training bundle](https://huggingface.co/maltefranke/morph/resolve/main/chembl3d/implicit_h/training_bundle.tar.gz) | + +For example, download and unpack the GEOM training bundle: + +```bash +hf download maltefranke/morph geom/explicit_h/training_bundle.tar.gz --local-dir . +tar -xzf geom/explicit_h/training_bundle.tar.gz +``` + +Each bundle also includes a portable training recipe; its changes from the +original run are recorded alongside it. See the [training bundle guide](docs/reproducibility.md) +for setup, data requirements and resuming. Version identifiers and checksums +are recorded in the release metadata and bundle manifests. + +## Training quick start + +Preprocessed splits, vocabularies, and statistics are available in the public +[Morph data repository](https://huggingface.co/datasets/maltefranke/morph-data). +Download a cache into this checkout and skip preprocessing: + +```bash +hf download maltefranke/morph-data --type dataset --include 'qm9/**' --local-dir data +``` + +Replace `qm9/**` with `geom/**`, `chembl3d/**`, `chembl3d_max100/**`, or +`chembl3d_max100_implicit_h/**` as needed. The released ChEMBL3D checkpoints use +the two `max100` caches; the dataset card documents the matching training bundles. + +Install [uv](https://docs.astral.sh/uv/) and use Python 3.11 or newer. +From the repository root: + +```bash +bash setup_env.sh +source .venv/bin/activate +source .env + +# Choose qm9, geom, or chembl3d; the workflow is the same for each. +dataset=chembl3d +python scripts/preprocess.py "$dataset" +python run.py defaults="$dataset/$dataset" +``` + +Setup installs the versions in `uv.lock` and creates `.env` only when it is +absent. Local outputs default to `outputs/`. To choose another artifact root, +set `MORPH_SCRATCH` before running setup. Training uses W&B by default; add +`logging.enabled=false` for a local run without W&B logging. + +`scripts/preprocess.py` downloads missing source files into `data//raw/`, +processes the train/validation/test splits, and computes vocabularies and training +statistics in `data//processed/`. Rerunning it reuses completed files. +No manual download or source directory is required. Run preprocessing once on a +CPU worker before starting distributed training; add `--workers 20` to choose +the number of preprocessing workers. + +GEOM and ChEMBL3D support heavy-atom graphs with implicit hydrogens. Choose +`--hydrogen-mode implicit` when preprocessing, then select the matching recipe: + +```bash +python scripts/preprocess.py "$dataset" --hydrogen-mode implicit +python run.py defaults="$dataset/implicit_h" +``` + +Set `dataset` to `geom` or `chembl3d` for this option. It uses a separate +`data/_implicit_h/` cache with its own vocabularies and statistics. +See [training and data options](docs/training.md) for filters and optional local +source overrides. + +GPU training compiles both current and EMA models by default. For short smoke +tests, use `MORPH_COMPILE=0`. The full training defaults require a GPU; a small +CPU run can be requested explicitly: + +```bash +MORPH_COMPILE=0 python run.py logging.enabled=false \ + trainer.trainer.accelerator=cpu trainer.trainer.devices=1 \ + trainer.trainer.strategy=auto integrator.integrator.num_integration_steps=2 \ + trainer.trainer.precision=32-true +trainer.trainer.fast_dev_run=true \ + data.datamodule.num_workers.train=0 data.datamodule.batch_size.train=2 \ + data.datamodule.batch_size.val=2 data.datamodule.batch_size.test=2 +``` + +## Scaffold decoration + +Fine-tune a pretrained unconditional DiT while conditioning on retained atoms: + +```bash +python run.py defaults=qm9/scaffold_decoration \ + model.module.pretrained_ckpt=/path/to/unconditional.ckpt \ + data.scaffold_kind=rgroup +``` + +Supported retained structures are Murcko scaffolds, BRICS fragments (`rgroup`), +heavy atoms, and fragments for linker generation. See +[training and fine-tuning](docs/training.md) for their semantics and LoRA options. + +## Reading the implementation + +The main data path is: + +```text +Dataset → prior/target alignment → interpolation → collated training batch + → DiT endpoint/edit predictions → per-graph losses → optimizer/EMA +``` + +Sampling repeatedly decodes predictions, applies task masks, integrates edits, +and recenters coordinates. Output coordinates are restored to Angstroms. + +| Entry point | Purpose | +| --- | --- | +| [run.py](run.py) | Training, validation, and prediction orchestration | +| [scripts/preprocess.py](scripts/preprocess.py) | Automatic dataset download, processing and statistics | +| [model/build.py](src/morph/model/build.py) | Shared model and metric construction | +| [flow_matching/](src/morph/flow_matching/) | Assignment, interpolation, schedules, and integration | +| [dataset/core/](src/morph/dataset/core/) | Molecular batches and structured insertion targets | +| [model/lightning/](src/morph/model/lightning/) | Training lifecycle, losses, sampling, and EMA | +| [configs/](configs/) | Dataset and model recipes | +| [tests/model/](tests/model/) | Regression tests grouped by subject | + +The [model contract](src/morph/model/README.md) documents tensor shapes, +coordinate conventions, conditioning ports, and the loss-to-head mapping. + +## Further documentation + +- [Training, configurations, and fine-tuning](docs/training.md) +- [Inference and model bundles](docs/inference.md) +- [Reproducing released models and training runs](docs/reproducibility.md) +- [Runtime, compilation, and performance](docs/performance.md) +- [Euler cluster setup and SLURM](docs/euler.md) +- [Development and regression checks](docs/development.md) +- [Release metadata](docs/release.md) + +## Citation + +If you use Morph, please cite our [paper](https://arxiv.org/abs/2606.07239): + +```bibtex +@misc{franke2026generativemolecularmorphingflexiblesize, + title={Generative Molecular Morphing for Flexible-Size Design via Unbalanced Optimal Transport}, + author={Malte Franke and Stefan P. Schmid and Zarko Ivkovic and Kjell Jorner and Andreas Krause}, + year={2026}, + eprint={2606.07239}, + archivePrefix={arXiv}, + primaryClass={cs.LG}, + url={https://arxiv.org/abs/2606.07239}, +} +``` + +Machine-readable citation metadata is available in [CITATION.cff](CITATION.cff). +License details are pending. diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/callbacks/callbacks.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/callbacks/callbacks.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1fe407944f322f2e45a3f2789d65ac6dbcfbc112 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/callbacks/callbacks.yaml @@ -0,0 +1,20 @@ +# @package _global_ + +## Put your callback args here +callbacks: + monitor_metric: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},val/validity,val/batch/pair_dist_l1} + monitor_metric_mode: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},max,min} + + # model_checkpoints -> best-by-metric (top-1, monitored) + # every_n_epochs_checkpoint -> latest (no monitor; overwrites every epoch) + model_checkpoints: + save_top_k: 1 + save_last: False + verbose: False + + every_n_epochs_checkpoint: + every_n_epochs: 1 + save_top_k: 1 + save_last: False + verbose: False + save_on_train_epoch_end: True # Force per-train-epoch firing diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/chembl3d.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1d8c2a257fbe3e481311ea14796c419d6430f996 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/chembl3d.yaml @@ -0,0 +1,8 @@ +# LoQI ChEMBL3D source format, without dataset-specific size/element filters. +defaults: + - /data/molecular@_here_ + - _self_ + +dataset_name: chembl3d +dataset_target: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset +source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/implicit_h.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..aed8c377319531469115e8ab7684ee8721cf45b1 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/implicit_h.yaml @@ -0,0 +1,5 @@ +defaults: + - /data/chembl3d/chembl3d@_here_ + - _self_ + +hydrogen_mode: implicit diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/max100.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/max100.yaml new file mode 100644 index 0000000000000000000000000000000000000000..62f9a2adf37959b4ebfb757a16fe50512e7d3295 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/max100.yaml @@ -0,0 +1,7 @@ +# Inclusive total-atom limit, applied before hydrogen removal. +defaults: + - /data/chembl3d/chembl3d@_here_ + - _self_ + +max_atoms: 100 +cache_name: ${data.dataset_name}_max${data.max_atoms}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/max100_implicit_h.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/max100_implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..005cef8a084acdae21f91fea19615b12b4a3b315 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/chembl3d/max100_implicit_h.yaml @@ -0,0 +1,5 @@ +defaults: + - /data/chembl3d/max100@_here_ + - _self_ + +hydrogen_mode: implicit diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/crossdocked/finetune.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/crossdocked/finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..814e9cf13170e37102bc4b0b0b7b15dede689a19 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/crossdocked/finetune.yaml @@ -0,0 +1,67 @@ +defaults: + - /data/molecular@_here_ + - _self_ + +dataset_name: crossdocked +dataset_target: morph.dataset.conditional.pocket.CrossDockedPocketDataset +hydrogen_mode: implicit +root: ${data.root_path}/crossdocked +source_dir: ${oc.env:MORPH_CROSSDOCKED_RAW,null} +pretrained_assets_root: ${oc.env:MORPH_PRETRAINED_ASSETS} +pretrain_root: ${data.root_path}/chembl3d_implicit_h +pretrain_source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} +pretrain_max_atoms: null +pretrain_allowed_atom_symbols: null +finetune_fraction: 0.5 # p / 100, sampled per example with replacement +train_epoch_size: null # source draws per epoch; defaults to retained fine-tuning size +pocket_vocab: null # Filled from TRAIN pocket elements during datamodule setup. +max_pocket_atoms: 512 +val_fraction: 0.02 + +# Load the exact artifacts associated with the pretrained checkpoint. +# Never discover a new ligand vocabulary/normalization for fine-tuning. +preprocessing: + root: ${data.pretrained_assets_root} + train_dataset: null + atom_tokens_path: ${data.preprocessing.root}/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/distributions.pt + +datamodule: + _target_: morph.dataset.conditional.pocket.datamodule.PocketLightningDataModule + pocket_vocab: ${data.pocket_vocab} + finetune_fraction: ${data.finetune_fraction} + train_epoch_size: ${data.train_epoch_size} + mix_seed: ${seed} + pretrain_dataset: + _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.pretrain_root} + source_dir: ${data.pretrain_source_dir} + hydrogen_mode: ${data.hydrogen_mode} + max_atoms: ${data.pretrain_max_atoms} + allowed_atom_symbols: ${data.pretrain_allowed_atom_symbols} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + datasets: + train: + root: ${data.root} + allowed_atom_symbols: null + max_pocket_atoms: ${data.max_pocket_atoms} + val_fraction: ${data.val_fraction} + val: + - _target_: ${data.dataset_target} + root: ${data.root} + source_dir: ${data.source_dir} + hydrogen_mode: ${data.hydrogen_mode} + max_pocket_atoms: ${data.max_pocket_atoms} + val_fraction: ${data.val_fraction} + preprocess_workers: ${data.preprocess_workers} + test: + - _target_: ${data.dataset_target} + root: ${data.root} + source_dir: ${data.source_dir} + hydrogen_mode: ${data.hydrogen_mode} + max_pocket_atoms: ${data.max_pocket_atoms} + val_fraction: ${data.val_fraction} + preprocess_workers: ${data.preprocess_workers} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/crossdocked/scaffold_decoration.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/crossdocked/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..86e2088b925a146351e819c87d56d21b2d24efe2 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/crossdocked/scaffold_decoration.yaml @@ -0,0 +1,31 @@ +defaults: + - /data/crossdocked/finetune@_here_ + - _self_ + +# Morph's DiffDec task uses LibINVENT reaction cuts. Other shared cutters can +# be selected explicitly (murcko, rgroup, linker). +scaffold_kind: libinvent +scaffold_groups_dir: ${data.root}/scaffold_groups +enumerate_slicings: true +# Fixed scaffold/pocket pairs with repeated draws enable retention, recovery, +# within-condition uniqueness/diversity, RMSD, and scaffold-aware PoseBusters. +# Unlike a topology-only panel, identical scaffolds in different pockets remain +# distinct conditions. Splits with fewer pairs use all of them. +eval_panel: + n_scaffolds: 512 + samples_per_scaffold: 8 + seed: 42 +# Denoise movable scaffold coordinates; physical Angstroms, training only. +jitter_scale: 0.1 +insertion_assignment: nearest +interpolator: + insertion_assignment: ${data.insertion_assignment} + jitter_scale: ${data.jitter_scale} + +datamodule: + _target_: morph.dataset.conditional.pocket.scaffold_pocket.ScaffoldPocketDataModule + scaffold_groups_dir: ${data.scaffold_groups_dir} + scaffold_kind: ${data.scaffold_kind} + enumerate_slicings: ${data.enumerate_slicings} + eval_panel: ${data.eval_panel} + rotate: true diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/geom.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2dec6705c8ff3a78a6c126b392c5ad4400f9a910 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/geom.yaml @@ -0,0 +1,11 @@ +# GEOM-Drugs source format and default molecule subset. +defaults: + - /data/molecular@_here_ + - _self_ + +dataset_name: geom +dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset +source_dir: ${oc.env:GEOM_SOURCE_DIR,null} # Optional prefiltered pickle directory. +# Preserve the established GEOM subset. Limits apply before hydrogen removal. +max_atoms: 72 +allowed_atom_symbols: [H, C, N, O, S, F, Cl, Br, P, I] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/implicit_h.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..53fcd5513e4798e67b6ffd85a7786d783ede7e41 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/implicit_h.yaml @@ -0,0 +1,5 @@ +defaults: + - /data/geom/geom@_here_ + - _self_ + +hydrogen_mode: implicit diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/scaffold_decoration.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3f38b7d6935b4eff1b7ece8980b980158a223789 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/geom/scaffold_decoration.yaml @@ -0,0 +1,38 @@ +# GEOM-Drugs data config for the **scaffold decoration** task. +# +# Mirrors configs/data/qm9/scaffold_decoration.yaml but on GEOM-Drugs: source +# is the selected scaffold of each target; target is the full molecule. The model +# learns to grow decoration atoms while denoising the retained scaffold. +# +# Inherits from configs/data/geom/geom.yaml and overrides: +# - datamodule._target_ -> ScaffoldLightningDataModule +# +# The scaffold cache is built lazily into ``scaffold_groups_dir`` on first run +# (one file per split and scaffold kind) and reused afterwards. +defaults: + - /data/geom/geom@_here_ + - _self_ + +scaffold_groups_dir: ${data.preprocessing.root}/processed/scaffold_groups +scaffold_kind: rgroup # murcko, rgroup (BRICS), heavy (generate H), linker +# Physical Angstroms, per coordinate axis. Used only by training interpolation. +jitter_scale: 0.1 +# "substituent" restricts insertion parents to the target branch or its +# scaffold attachments. This changes training supervision, not atom OT. +insertion_assignment: nearest +interpolator: + jitter_scale: ${data.jitter_scale} + insertion_assignment: ${data.insertion_assignment} +# Exhaustive evaluation when eval_panel=null; training always draws randomly. +enumerate_slicings: false +eval_panel: + directory: ${data.scaffold_groups_dir}/eval_panels + n_scaffolds: 512 + samples_per_scaffold: 8 + seed: 42 +datamodule: + _target_: morph.dataset.conditional.scaffold.datamodule.ScaffoldLightningDataModule + scaffold_groups_dir: ${data.scaffold_groups_dir} + scaffold_kind: ${data.scaffold_kind} + enumerate_slicings: ${data.enumerate_slicings} + eval_panel: ${data.eval_panel} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/molecular.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/molecular.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a8d1975ed1b63f482b2110dd404ea56ffa46c15b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/molecular.yaml @@ -0,0 +1,136 @@ +# Shared drug-like molecular training and preprocessing configuration. + +root_path: ${oc.env:PROJECT_ROOT}/data +preprocess_workers: 10 +preprocess_chunk_size: 128 +statistics_workers: ${data.preprocess_workers} +hydrogen_mode: explicit +# The mode is part of the cache path; changing it discovers fresh vocab/statistics. +cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} +source_dir: null +max_atoms: null +allowed_atom_symbols: null + +n_atoms_strategy: "uniform" # "approx", "empirical", "learned" +# Uniform source-count support padding; explicit so inference and training can +# be matched or ablated without code changes. +uniform_n_atoms_padding: 5 +optimal_transport: "equivariant" +# Physical Angstroms (converted to normalized coordinates internally). These +# reproduce the former normalized 0.25 / 0.2 scales at GEOM's training-set std. +# Physical Angstroms. Insertion bridge noise vanishes as (1-t); move noise is +# the persistent training-time jitter floor for matched atoms and insertions +# that are already present. +ins_noise_scale: 0.65 +move_noise_scale: 0.5 + +# Drug-like datasets contain charged species; allow them in validity metrics. +allow_charged: true + + +# Preprocessing configuration - computes tokens from data and distributions from training dataset only +preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: ${data.root_path}/${data.cache_name} + num_workers: ${data.statistics_workers} + train_dataset: ${data.datamodule.datasets.train} + atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/processed/distributions.pt + # Tokens and distributions will be discovered from data and saved to files + # On subsequent runs, they will be loaded from the files + +vocab: null + +interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: null + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + # Training-only categorical corruption; pairs are scaled per molecule to + # match the expected number of changed atoms. Clean targets stay unchanged. + atom_type_noise_prob: 0.0 + # OT matching (cost always squared-Euclidean; move cost in Angstrom). c_move + # scales the squared move distance (A^2); c_ins/c_del/c_sub are fixed A^2 costs, + # directly comparable. At 6.125 each, delete+insert costs 3.5^2, so atoms + # farther than 3.5 A apart are left unmatched by default. + pre_align_iters: 2 # equivariant-OT inner loop (Hungarian+Kabsch) iters + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + +datamodule: + _target_: morph.dataset.core.lightning_datamodule.LightningDataModule + # tokens and distributions will be passed from preprocessing in run.py + + vocab: ${data.vocab} + distributions: null + representation: ${representation} + + datasets: + train: + _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + + val: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + + test: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + + interpolator: ${data.interpolator} + + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + + num_workers: + train: 10 + val: 0 + test: 0 + + batch_size: + train: 320 + val: 128 + test: 64 + + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/qm9/qm9.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..992049b566f18acb2744568adcf61e47ecfc2342 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/qm9/qm9.yaml @@ -0,0 +1,106 @@ +## QM9 data and preprocessing configuration. + +dataset_name: qm9 +root_path: ${oc.env:PROJECT_ROOT}/data +preprocess_workers: 0 + +n_atoms_strategy: "uniform" # "approx", "empirical", "learned" +# Expand both ends of the empirical count support for the uniform flow source. +# QM9's minimum is already the hard lower bound of 3, so positive padding is +# necessarily asymmetric; set to 0 for an unpadded 3--29 diagnostic prior. +uniform_n_atoms_padding: 5 +optimal_transport: "equivariant" +# Physical Angstroms (converted to normalized coordinates internally). Insertion +# bridge noise vanishes as (1-t); move noise is the persistent training-time +# jitter floor for matched atoms and insertions that are already present. +ins_noise_scale: 0.35 +move_noise_scale: 0.3 + +# QM9 contains only neutral molecules; charged species are treated as invalid. +allow_charged: false + + +# Preprocessing configuration - computes tokens from data and distributions from training dataset only +preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: ${data.root_path}/qm9 + train_dataset: ${data.datamodule.datasets.train} + # Canonical (representation-agnostic) cache. One preprocessing per dataset; + # representation-specific projection happens at runtime in the wrapper. + atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/processed/distributions.pt + +vocab: None + +interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: None + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + # OT matching (cost always squared-Euclidean; move cost in Angstrom). c_move + # scales the squared move distance (A^2); c_ins/c_del/c_sub are fixed A^2 costs, + # directly comparable. At 6.125 each, delete+insert costs 3.5^2, so atoms + # farther than 3.5 A apart are left unmatched by default. + pre_align_iters: 2 # equivariant-OT inner loop (Hungarian+Kabsch) iters + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + +datamodule: + _target_: morph.dataset.core.lightning_datamodule.LightningDataModule + # tokens and distributions will be passed from preprocessing in run.py + + vocab: ${data.vocab} + distributions: None + representation: ${representation} + + datasets: + train: + _target_: morph.dataset.unconditional.qm9.FlowMatchingQM9Dataset + root: ${data.root_path}/qm9 + vocab: ${data.vocab} + distributions: None + + val: + - _target_: morph.dataset.unconditional.qm9.FlowMatchingQM9Dataset + root: ${data.root_path}/qm9 + vocab: ${data.vocab} + distributions: None + + test: + - _target_: morph.dataset.unconditional.qm9.FlowMatchingQM9Dataset + root: ${data.root_path}/qm9 + vocab: ${data.vocab} + distributions: None + + interpolator: ${data.interpolator} + + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + + num_workers: + train: 10 + val: 0 + test: 0 + + batch_size: + train: 1024 + val: 128 + test: 32 + + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/qm9/scaffold_decoration.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..26e1671de298350a66747de32b92171d5872cc12 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/data/qm9/scaffold_decoration.yaml @@ -0,0 +1,32 @@ +# QM9 data config for the **scaffold decoration** task. +# +# Source is the selected scaffold of each target; target is the full molecule. +# The model learns to grow the molecule by inserting decoration atoms +# while denoising the retained scaffold. +# +# Inherits from configs/data/qm9/qm9.yaml and overrides: +# - datamodule._target_ → ScaffoldLightningDataModule +# +defaults: + - /data/qm9/qm9@_here_ + - _self_ + +scaffold_groups_dir: ${data.root_path}/qm9/processed/scaffold_groups +scaffold_kind: rgroup # murcko, rgroup (BRICS), heavy (generate H), linker +# Physical Angstroms, per coordinate axis. Used only by training interpolation. +jitter_scale: 0.1 +interpolator: + jitter_scale: ${data.jitter_scale} +# Exhaustive evaluation when eval_panel=null; training always draws randomly. +enumerate_slicings: false +eval_panel: + directory: ${data.scaffold_groups_dir}/eval_panels + n_scaffolds: 512 + samples_per_scaffold: 8 + seed: 42 +datamodule: + _target_: morph.dataset.conditional.scaffold.datamodule.ScaffoldLightningDataModule + scaffold_groups_dir: ${data.scaffold_groups_dir} + scaffold_kind: ${data.scaffold_kind} + enumerate_slicings: ${data.enumerate_slicings} + eval_panel: ${data.eval_panel} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/default.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..62ac4294601048087b24a7825a283abf44a2d68e --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/default.yaml @@ -0,0 +1,25 @@ +defaults: + - _self_ + # Single-knob presets bundle + # `data` + `embeddings` (with dataset-tuned ExtrapolatableCount params) + # plus the shared `core` group selections. Override individual groups as + # usual, e.g. `defaults=geom/geom model=backbones/dit embeddings=qm9`. + - defaults: qm9/qm9 + +# What the model trains on. Datasets declare their CAPABILITIES; run.py validates +# the requested mode against them before preprocessing. +# pointcloud — x, a only (charges and edges are dummy singletons) +# charged_pointcloud — x, a, c (edges are dummy singletons) +# geometric_graph — x, a, edge_index, e (charges are dummy neutral) +# molecule — full chemistry (current behavior) +representation: molecule +seed: 42 + +# Run directory: ////. Root is +# MORPH_RUN_ROOT (scratch, set in .env) so every run's outputs/checkpoints/wandb +# land off-repo; falls back to /outputs for ad-hoc runs with no env set. +# wandb id is MORPH_RUN_ID (same as WANDB_RUN_ID). Set in the shell before +# torchrun, or set automatically for single-process runs in run.py before Hydra composes. +hydra: + run: + dir: ${oc.env:MORPH_RUN_ROOT,${hydra:runtime.cwd}/outputs}/${hydra:runtime.choices.data}/${now:%Y-%m-%d}/${oc.env:MORPH_RUN_ID} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/chembl3d.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..da60c3219bac2bd1cc23d5cf1eaca85084c2cb89 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/chembl3d.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - core + - /data: chembl3d/chembl3d + - /embeddings: geom diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/implicit_h.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..132977af082d8fe9724b85773371f19049621c6a --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/implicit_h.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: chembl3d/implicit_h + - /embeddings: geom diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..640868cdedc8b9216e8a77238ef22cd183977353 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml @@ -0,0 +1,10 @@ +# @package _global_ +defaults: + - /defaults/chembl3d/train_2x4090 + - override /data: chembl3d/max100_implicit_h + - _self_ + +# Train from scratch: the atom vocabulary/statistics differ from the H model. +# Preserve global batch 640, eval every epoch, both checkpoint callbacks every 5. +logging: + project: morph-chembl3d-implicit-h diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/train_2x4090.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/train_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0f8da8debe24d260bc2fb7854e9905d31e9548b1 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/chembl3d/train_2x4090.yaml @@ -0,0 +1,40 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: chembl3d/max100 + - /embeddings: geom + - _self_ + +# Profiled on max100 ChEMBL: 2 GPUs * 320 * 1 = 640 effective global batch. +data: + datamodule: + train_prefetch_factor: 1 + batch_size: + train: 320 + val: 32 + test: 32 + num_workers: + train: 20 + val: 2 + test: 2 + +trainer: + trainer: + accelerator: gpu + devices: 2 + accumulate_grad_batches: 1 + max_epochs: -1 + check_val_every_n_epoch: 1 + num_val_samples: 4096 + # Validation runs during fit; leave wall-clock headroom for final checkpointing. + do_validate: false + do_predict: false + final_checkpoint_path: final.ckpt + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/core.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/core.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a8a7114d33bddc4ec3a5011213e925285a426e40 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/core.yaml @@ -0,0 +1,13 @@ +# @package _global_ +# Shared default groups that don't change between QM9 / GEOM runs. +# Dataset-specific presets (defaults/qm9/qm9.yaml, defaults/geom/geom.yaml) inherit +# from this and only add `data` + `embeddings`. +defaults: + - /heads: head + - /insertion_head: unimodal + - /model: backbones/dit + - /schedule: smoothstep + - /trainer: trainer + - /callbacks: callbacks + - /logging: wandb_logger + - /integrator: integrator diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/finetune.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..407f49465c29f6679620508ac2729359ec317576 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/finetune.yaml @@ -0,0 +1,16 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: crossdocked/finetune + - /embeddings: geom + - override /model: finetune/pocket_finetune + - _self_ + +trainer: + trainer: + # ReplaySampler already partitions its stream between DDP ranks. + use_distributed_sampler: false + check_val_every_n_epoch: 1 + +logging: + project: morph-crossdocked-finetune diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/scaffold_decoration.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0212c72e79b3c40803d899b96ef149ade5589e12 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/scaffold_decoration.yaml @@ -0,0 +1,9 @@ +# @package _global_ +defaults: + - /defaults/crossdocked/finetune + - override /data: crossdocked/scaffold_decoration + - override /model: finetune/scaffold_pocket_finetune + - _self_ + +logging: + project: morph-crossdocked-scaffold-finetune diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/scaffold_decoration_2x4090.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/scaffold_decoration_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d1968fb36d2c44dfd0572701d7f370a70c2bedb4 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/crossdocked/scaffold_decoration_2x4090.yaml @@ -0,0 +1,51 @@ +# @package _global_ +defaults: + - /defaults/crossdocked/scaffold_decoration + - _self_ + +data: + pretrain_root: ${data.root_path}/chembl3d_max100_implicit_h + pretrain_max_atoms: 100 + finetune_fraction: 0.5 + # 400 optimizer updates per epoch at global batch 256. + train_epoch_size: 102400 + datamodule: + train_prefetch_factor: 1 + batch_size: + train: 32 + val: 8 + test: 8 + num_workers: + train: 6 + val: 2 + test: 2 + +model: + module: + # LoRA, conditioning modules, and complete prediction heads train with replay. + # Peak Muon/Adam rates are inherited from scaffold_pocket_finetune. + optimizer_config: + scheduler: + warmup_steps: 1000 + total_steps: 40000 + +trainer: + trainer: + accelerator: gpu + devices: 2 + accumulate_grad_batches: 4 + max_epochs: 100 + check_val_every_n_epoch: 5 + # Evaluate the complete repeated panel; truncation biases conditional scores. + num_val_samples: null + do_validate: false + do_predict: false + final_checkpoint_path: final.ckpt + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/geom.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c9a4106610671357d9e2d8b0ca39bdbcca5c8f9d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/geom.yaml @@ -0,0 +1,20 @@ +# @package _global_ +# GEOM preset: shared core + GEOM data and embeddings. +defaults: + - core + - /data: geom/geom + - /embeddings: geom + - _self_ + +# Evaluate every epoch; keep every fifth completed epoch for releases/resuming. +trainer: + trainer: + check_val_every_n_epoch: 1 + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/implicit_h.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6d448b2e063b23e1f95e00129a168b6751adb5e1 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/implicit_h.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: geom/implicit_h + - /embeddings: geom diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8a2cae50289c52e35320895b971877894b2b02b8 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration.yaml @@ -0,0 +1,39 @@ +# @package _global_ +# GEOM-Drugs **scaffold decoration** fine-tune preset. +# +# Mirrors defaults/qm9/scaffold_decoration.yaml on GEOM-Drugs. Bare-scaffold +# source -> full-molecule target: the model learns to grow a molecule by +# inserting decoration atoms while allowing scaffold coordinates to move. +# +# Differences from the QM9 scaffold preset: +# - data -> geom/scaffold_decoration (GEOM base datasets) +# - embeddings -> geom +# +# Reuses the scaffold_finetune model config (loads the pretrained unconditional +# DiT into the clean lane + adds the node/edge conditioning and the gated +# cross-attn blocks). +# +# Pair with a GEOM unconditional DiT checkpoint via +# ``model.module.pretrained_ckpt=...``. +defaults: + - /defaults/core + - /data: geom/scaffold_decoration + - /embeddings: geom + - override /model: finetune/scaffold_finetune + - _self_ + +# Evaluate each completed epoch; retain checkpoints every five epochs. +trainer: + trainer: + check_val_every_n_epoch: 1 + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link + +logging: + project: morph-${data.dataset_name}-scaffold-decoration diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration_2x4090.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..931bdde312907a42b3e84600609c3a74cca22c06 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration_2x4090.yaml @@ -0,0 +1,27 @@ +# @package _global_ +defaults: + - /defaults/geom/scaffold_decoration + - _self_ + +# Starting point for future GEOM scaffold runs on two 24-GiB GPUs. +# The epoch-100 Murcko runs used 160/GPU with accumulation 2; reported GPU +# memory usage was only 33% (full) / 29% (LoRA). Double the microbatch while +# preserving the effective batch: 2 GPUs * 320 * 1 = 640. +# Profile peak memory and throughput before choosing still larger batches. +data: + datamodule: + train_prefetch_factor: 1 + batch_size: + train: 320 + val: 32 + test: 32 + num_workers: + train: 20 + val: 0 + test: 0 + +trainer: + trainer: + accelerator: gpu + devices: 2 + accumulate_grad_batches: 1 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration_scratch_2x4090.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration_scratch_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..abb4efa9146e188d6b47b0bbf993946aa9b8f290 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/geom/scaffold_decoration_scratch_2x4090.yaml @@ -0,0 +1,44 @@ +# @package _global_ +# Train GEOM-Drugs Murcko decoration from scratch, using the scaffold protocol. +defaults: + - /defaults/geom/scaffold_decoration_2x4090 + - _self_ + +seed: 42 + +data: + scaffold_kind: murcko + jitter_scale: 0.1 + +model: + module: + pretrained_ckpt: null + freeze_pretrained_backbone: false + freeze_output_heads: false + freeze_keep_positions: false + exclude_scaffold_from_x_loss: false + optimizer_config: + optimizer: + lr: 1e-3 + aux_optimizer: + lr: 1e-4 + lora: + enabled: false + freeze_base: false + +trainer: + trainer: + max_epochs: 50 + check_val_every_n_epoch: 1 + num_val_samples: 4096 + enable_progress_bar: false + max_time: + hours: 47 + checkpoint_path: null + final_checkpoint_path: final.ckpt + do_validate: false + do_predict: false + +logging: + group: geom-murcko-scratch + tags: [geom, explicit-h, murcko, scaffold-decoration, scratch, full-model, movable, jitter-0.1A] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/qm9.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6b8db22183af2a6901670759e065cde11037cbb2 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/qm9.yaml @@ -0,0 +1,20 @@ +# @package _global_ +# QM9 preset: shared core + QM9 data and embeddings. +defaults: + - /defaults/core + - /data: qm9/qm9 + - /embeddings: qm9 + - _self_ + +trainer: + trainer: + check_val_every_n_epoch: 1 + +# Keep the full 25-epoch checkpoint history for model releases and resuming. +callbacks: + model_checkpoints: + every_n_epochs: 25 + every_n_epochs_checkpoint: + every_n_epochs: 25 + save_top_k: -1 + save_last: link diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/scaffold_decoration.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..73738d2649958428da17a643353efd875a03edf6 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/scaffold_decoration.yaml @@ -0,0 +1,27 @@ +# @package _global_ +# QM9 **scaffold decoration** fine-tune preset. +# +# Bare-scaffold source → full-molecule target. The model learns to grow +# a molecule by inserting decoration atoms while scaffold coordinates may move. +defaults: + - core + - /data: qm9/scaffold_decoration + - /embeddings: qm9 + - override /model: finetune/scaffold_finetune + - _self_ + +# Evaluate each completed epoch; retain checkpoints every five epochs. +trainer: + trainer: + check_val_every_n_epoch: 1 + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link + +logging: + project: morph-${data.dataset_name}-scaffold-decoration diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/train_2x4090.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/train_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..00f833d74c7497166492ff6fdf32c1d7ddb694fa --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/defaults/qm9/train_2x4090.yaml @@ -0,0 +1,29 @@ +# @package _global_ +defaults: + - /defaults/qm9/qm9 + - _self_ + +# Preserve the established QM9 effective batch: 2 GPUs * 1024 * 2 = 4096. +data: + datamodule: + train_prefetch_factor: 1 + batch_size: + train: 1024 + val: 128 + test: 32 + num_workers: + train: 20 + val: 2 + test: 2 + +trainer: + trainer: + accelerator: gpu + devices: 2 + accumulate_grad_batches: 2 + max_epochs: 3000 + check_val_every_n_epoch: 1 + num_val_samples: 4096 + do_validate: false + do_predict: false + final_checkpoint_path: final.ckpt diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/embeddings/geom.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/embeddings/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..51d9fda0862b44cff795661c2f1e91217c7743ab --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/embeddings/geom.yaml @@ -0,0 +1,28 @@ +# Shared embedding stack tuned for GEOM-Drugs. +# - node_count_embedding uses ExtrapolatableCountEmbedding with periods/max_count +# scaled up for the much larger atom counts in GEOM-Drugs. + +atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + +edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + +time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + +node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 128.0 + max_period: 1024.0 + max_count: 96.0 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/embeddings/qm9.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/embeddings/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4dcc81608316e2105165576aaf8d915e2317f97d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/embeddings/qm9.yaml @@ -0,0 +1,29 @@ +# Shared embedding stack tuned for QM9. +# - node_count_embedding uses ExtrapolatableCountEmbedding so the model can +# generalise slightly outside the training atom-count support. +# - Defaults sized for QM9 (training counts in [3, 29]). + +atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + +edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + +time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + +node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 64.0 + max_period: 512.0 + max_count: 32.0 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/heads/head.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/heads/head.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bef544d639b98055af99cb253ea8dc0a82486473 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/heads/head.yaml @@ -0,0 +1,75 @@ +# Standard molecular DiT readouts. The insertion-content head is an independent +# Hydra choice (``insertion_head=unimodal`` by default, or ``=gmm``). + +heads: + _target_: morph.model.layers.heads.MultiHeadModule + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + + heads_configs: + graph_aggregation: mean + + node_heads: + atom_type_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.atom_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + + charge_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.charge_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + + ins_rate_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_rate: ${model.head_initialization.ins_rate} + + do_del_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_del_probability} + + do_sub_a_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_sub_a_probability} + + edge_heads: + edge_type_head: + input_dim: ${model.head_edge_dim} + output_dim: ${len:${data.vocab.edge_tokens}} + adapter_dim: ${model.head_edge_adapter_dim} + group: state + + do_sub_e_head: + input_dim: ${model.head_edge_dim} + output_dim: 1 + adapter_dim: ${model.head_edge_adapter_dim} + group: state + initial_probability: ${model.head_initialization.do_sub_e_probability} + +ins_edge_head: + _target_: morph.model.layers.heads.InsertionEdgeHead + _recursive_: false + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_edge_dim} + output_init_std: ${model.head_initialization.output_std} + n_edge_types: ${len:${data.vocab.edge_tokens}} + n_atom_types: ${len:${data.vocab.atom_tokens}} + n_charge_types: ${len:${data.vocab.charge_tokens}} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/insertion_head/gmm.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/insertion_head/gmm.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5dedca7c0ff62a5d3fcfaf43de5aa79a161d813f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/insertion_head/gmm.yaml @@ -0,0 +1,16 @@ +# Optional mixture-density insertion-content head. +mode: gmm +dimension: 3 +num_components: 10 + +model: + _target_: morph.model.layers.heads.GMMInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + num_components: ${insertion_head.num_components} + sigma_min: 0.05 + sigma_max: 2.0 + sigma_init: 0.5 + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/insertion_head/unimodal.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/insertion_head/unimodal.yaml new file mode 100644 index 0000000000000000000000000000000000000000..85148799b512c78531aa577a18d36bbdc70f9cba --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/insertion_head/unimodal.yaml @@ -0,0 +1,13 @@ +# Default insertion-content head: one nearest pending atom per spawning node. +mode: unimodal +dimension: 3 +num_components: 1 + +model: + _target_: morph.model.layers.heads.UnimodalInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/integrator/integrator.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/integrator/integrator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0c167b0138fc54d8413743649543e4b2428c05a --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/integrator/integrator.yaml @@ -0,0 +1,30 @@ +integrator: + _target_: morph.flow_matching.integration.RateIntegrator + vocab: ${data.vocab} + distributions: None + insertion_head_mode: ${insertion_head.mode} + insertion_dimension: ${insertion_head.dimension} + insertion_num_components: ${insertion_head.num_components} + # strategy used for sampling time during **integration** + # NOTE can be different from the time strategy used for the model (see below) + time_strategy: log # "linear", "log", or "beta" + # shape params for time_strategy: beta (Beta inverse-CDF spacing). + # defaults match the model's beta_sched (concentration1=2.0, concentration0=1.0) + time_beta_alpha: 2.0 + time_beta_beta: 1.0 + num_integration_steps: 100 + ins_noise_scale: ${data.ins_noise_scale} + # When true, freshly-inserted nodes are pushed to the t+dt marginal (kappa-mix + # on atom/bond types, (1-t)*ins_noise on x) to match training. Charge remains + # a clean prediction target rather than an interpolated state. When false, + # insertions enter at the model's clean prediction. + renoise_insertions: true + # Integrate the known categorical schedule hazard over every time interval + # instead of using its left-endpoint Euler approximation. + # With either method, the final interval always completes pending discrete + # transitions using the learned edit probabilities (no extra model call). + exact_schedule_integration: true + del_schedule: ${data.interpolator.del_schedule} + ins_schedule: ${data.interpolator.ins_schedule} + sub_schedule: ${data.interpolator.sub_schedule} + sub_e_schedule: ${data.interpolator.sub_e_schedule} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/logging/wandb_logger.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/logging/wandb_logger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..162b88b38e78138334d36469b72bccb633924517 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/logging/wandb_logger.yaml @@ -0,0 +1,6 @@ +## Put your model args here +enabled: true +project: morph-${data.dataset_name} +name: ${oc.env:MORPH_RUN_ID,base} +save_dir: . +offline: false diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/calibrated_geom.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/calibrated_geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ddc9b38bc200adc1832e091277ad3aeb88e89a5d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/calibrated_geom.yaml @@ -0,0 +1,18 @@ +# @package model.module.loss_weights +# Balance-only (geometric-mean=1) calibration weights, scaled down by a global +# factor of 0.2 (1/5). At the original geometric-mean=1 scale the realized +# full-model grad-norm was ~5, far above gradient_clip_val=1.0, so clipping +# fired on every step. The 0.2 factor brings the realized norm to ~1.0 so the +# clip becomes a spike safety-valve again. Re-running calibration now applies +# this scaling automatically (see trainer.target_total_grad_norm). +c: 0.164 +do_del: 0.20 +do_sub_a: 0.30 +do_sub_e: 0.24 +ins_e: 0.21 +ins_e_ii: 0.22 +ins_state: 0.14 +ins_rate: 0.20 +sub_a_class: 0.15 +sub_e_class: 0.19 +x: 0.22 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/calibrated_geom_frozen.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/calibrated_geom_frozen.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0e9e3b6f7e1814dce089db7a5b9497f3e7fd860 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/calibrated_geom_frozen.yaml @@ -0,0 +1,25 @@ +# @package model.module.loss_weights +# Balance-only (geometric-mean = 1) loss weights — ratios only. The learning +# rate owns step size and gradient_clip_val stays a pure spike catch, so these +# are NOT scaled to a target grad-norm (unlike the older calibrated_geom.yaml, +# which baked in a ~0.2 global scale to hit the clip target). +# +# Source: frozen init-state calibration (trainer.calibrate_mode=loss, +# calibrate_freeze_model=true), geom / DiT / uncond, +# run 2026-06-07 (outputs/geom/uncond/cal_then_lr/2026-06-07_12-29-25/calibrate). +# Because the model is held at init, every probe batch is an unbiased snapshot of +# the initial per-term losses — so the three binary do_* losses come out equal +# (all BCE at p=0.5 = ln2), unlike the previous 2500-step *training* probe +# (calibrated_geom.yaml) which gave them different weights purely from differing +# training rates. See notebooks/lr_calibration_analysis.ipynb for the comparison. +c: 0.745146 +do_del: 1.730107 +do_sub_a: 1.730107 +do_sub_e: 1.730106 +ins_e: 0.867232 +ins_e_ii: 0.867232 +ins_state: 0.519379 +ins_rate: 1.128104 +sub_a_class: 0.522109 +sub_e_class: 0.867232 +x: 1.298792 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/tuned_weights.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/tuned_weights.yaml new file mode 100644 index 0000000000000000000000000000000000000000..681de9e73d6cd6836226366549221edb368be363 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/tuned_weights.yaml @@ -0,0 +1,15 @@ +# @package model.module.loss_weights +# Canonical tuned weights for molecular training. Backbones can select this +# preset explicitly, and task-specific experiments may override the group. The +# package directive mounts the values under model.module.loss_weights. +do_sub_a: 0.2 +sub_a_class: 0.03 +do_sub_e: 0.25 +sub_e_class: 0.5 +do_del: 0.4 +ins_rate: 0.15 +ins_state: 0.01 +ins_e: 0.4 +x: 2.0 # was 1.0 +c: 0.5 +ins_e_ii: 0.03 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/uniform.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/uniform.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fb279b4f7a159f8098ad5aa9b06059570949c270 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/loss_weights/uniform.yaml @@ -0,0 +1,15 @@ +# @package model.module.loss_weights +# Uniform 1.0 weight per loss component. The key set matches +# LightningModuleRates.LOSS_GROUPS exactly (the 11 actual loss terms +# emitted by shared_step). +do_sub_a: 1.0 +sub_a_class: 1.0 +do_sub_e: 1.0 +sub_e_class: 1.0 +do_del: 1.0 +ins_rate: 1.0 +ins_state: 1.0 +ins_e: 1.0 +ins_e_ii: 1.0 +x: 1.0 +c: 1.0 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/backbones/dit.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/backbones/dit.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5393a940e3628f9d0b299eb543b8e619ad03adaf --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/backbones/dit.yaml @@ -0,0 +1,123 @@ +## Molecular DiT: the repository's first-class generative backbone. +## +## This intentionally keeps charge out of the state and adds neither an +## equivariant lane nor an evolving dense pair state. It retains the atom-count +## condition: time plus current graph size disambiguates otherwise identical +## local states and is especially useful while insertion/deletion is active. +defaults: + - /optimizer: muon_aux_adam + - /loss_weights: tuned_weights + - _self_ + +rotate: true +n_augmentations: 1 + +integrator: ${integrator.integrator} + +beta_sched: + _target_: torch.distributions.Beta + concentration1: 2.0 + concentration0: 1.0 + +linear_sched: + _target_: torch.distributions.Uniform + low: 0.0 + high: 1.0 + +sample_time_strategy: linear + +module: + _target_: morph.model.lightning.base.LightningModuleRates + insertion_head_mode: ${insertion_head.mode} + ins_noise_scale: ${data.ins_noise_scale} + ema_decay: 0.999 + ema_warmup_steps: 5000 + use_ema_for_eval: true + time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched}, ${model.linear_sched}} + integrator: ${model.integrator} + vocab: ${data.vocab} + use_learnable_loss_weights: false + use_time_weights: false + + model: + _target_: morph.model.backbones.dit.DiTBackboneWithHeads + _recursive_: false + + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + # Sum the existing incident edge embeddings into each atom's input, excluding + # NO_BOND, through a zero-initialized projection. Uses the existing heads. + incident_bond_embedding: false + backbone_model_args: ${model.backbone} + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + +head_node_dim: ${model.backbone.out_node_nf} +head_edge_dim: ${model.backbone.out_edge_nf} + +# Shared adapters avoid seven separate full-width MLPs. At the >150M preset, +# node/insertion adapters widen while pair readouts remain compact. +head_node_adapter_dim: 256 +head_edge_adapter_dim: 256 +head_insertion_adapter_dim: 256 +head_insertion_edge_dim: 256 +head_independent_adapters: true + +# Categorical biases are replaced at module construction with exact dataset +# marginals. There is no dataset-level transition-frequency statistic for the +# binary/rate heads, so their defaults deliberately preserve the old neutral +# predictions instead of inventing a corpus-independent edit prior. +head_initialization: + output_std: 1.0e-3 + coordinate_output_std: 0.0 + do_del_probability: 0.5 + do_sub_a_probability: 0.5 + do_sub_e_probability: 0.5 + ins_rate: 0.6931471805599453 + +token_weighting: + type_loss_token_weights: uniform + weight_alpha: 0.4 + +# Optional low-rank adaptation of the attention and SwiGLU projections. The +# adapter residual is zero at initialization, so enabling this while loading a +# pretrained checkpoint preserves its outputs exactly. +lora: + enabled: false + rank: 8 + alpha: 8.0 + dropout: 0.0 + targets: [qkv, proj, mlp.gate_value, mlp.out_proj] + freeze_base: true + +backbone: + _target_: morph.model.backbones.dit.DiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/lora_finetune.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/lora_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7fe1aa5d969a2520844f526c882dc07f4c5024ed --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/lora_finetune.yaml @@ -0,0 +1,15 @@ +# Parameter-efficient fine-tuning of the unconditional DiT. +defaults: + - /model/backbones/dit@_here_ + - _self_ + +lora: + enabled: true + +module: + _target_: morph.model.finetune.base.LightningModuleRatesFinetune + pretrained_ckpt: ${oc.env:MORPH_PRETRAINED_CKPT} + pretrained_prefer_ema: true + freeze_pretrained_backbone: true + # Set false to train complete output heads alongside LoRA. + freeze_output_heads: ${.freeze_pretrained_backbone} diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/pocket_finetune.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/pocket_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9ae5bfeef4ae1f2ce2bc592dd1cfc2181b8bf44d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/pocket_finetune.yaml @@ -0,0 +1,31 @@ +defaults: + - /model/backbones/dit@_here_ + - _self_ + +module: + _target_: morph.model.finetune.base.LightningModuleRatesFinetune + pretrained_ckpt: ${oc.env:MORPH_PRETRAINED_CKPT} + pretrained_prefer_ema: true + # Replay supplies a retention objective for the trainable pretrained weights. + freeze_pretrained_backbone: false + freeze_output_heads: false + model: + _target_: morph.model.finetune.pocket.PocketConditionalDiTBackboneWithHeads + encoder_args: ${model.pocket_encoder} + use_distance_bias: true + +backbone: + _target_: morph.model.backbones.conditional_dit.ConditionalDiTBackbone + ctx_dim: 128 + use_cross_attn: true + use_node_cond: false + use_edge_cond: false + +pocket_encoder: + _target_: morph.model.finetune.pocket.PocketContextEncoder + num_atom_types: ${len:${data.pocket_vocab.atom_tokens}} + num_charges: ${len:${data.pocket_vocab.charge_tokens}} + ctx_dim: ${model.backbone.ctx_dim} + num_attn_layers: 2 + nhead: 8 + dropout: 0.0 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/scaffold_finetune.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/scaffold_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7eab97ca9b3bf1a65a4bd210f76fcf080096adf3 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/scaffold_finetune.yaml @@ -0,0 +1,140 @@ +# DiT backbone augmented with Flamingo-style gated cross-attention for +# scaffold-decoration fine-tuning. +# +# Conditioning (all derived from mol_t.keep_mask): +# - node_cond : per-atom scaffold membership (always on) +# - edge_cond : per-edge scaffold class (always on) +# - cross-attn: scaffold subgraph encoded by a shared LightweightContextEncoder +# (OPTIONAL — toggle via backbone.use_cross_attn): +# * false → option 1: node+edge only +# * true → option 2: node+edge+cross-attn +# +# Inherits everything from configs/model/backbones/dit.yaml and overrides: +# - module._target_ → LightningModuleRatesScaffoldFinetune (loads a pretrained +# unconditional DiT checkpoint and adds scaffold loss/inference masking) +# - model._target_ → ScaffoldConditionalDiTBackboneWithHeads (auto-builds the +# node/edge cond and, when use_cross_attn, the scaffold cross-attn tokens) +# - backbone._target_ → ConditionalDiTBackbone (DiT + gated cross-attn blocks) +# +# Provides a ``pretrained_ckpt`` field for the lightning module to load. +defaults: + - /model/backbones/dit@_here_ + - _self_ + +module: + _target_: morph.model.finetune.scaffold.LightningModuleRatesScaffoldFinetune + # Path to the unconditional DiT checkpoint to load weights from. + # ConditionalDiT keys are auto-retargeted (backbone.* → backbone.dit.*). + pretrained_ckpt: ${oc.env:MORPH_PRETRAINED_CKPT} + pretrained_prefer_ema: true + # If true, dispatches to ``self.model.freeze_pretrained()`` — for the + # cross-attn scaffold model this freezes the entire clean lane + # (embedding, ``backbone.dit``, all heads). Only the gated cross-attn + # blocks and edge gate plus the scaffold encoder train. + freeze_pretrained_backbone: true + # Independently freeze prediction/insertion heads and the coordinate readout. + # Follow backbone freezing by default; false trains heads with a frozen backbone. + freeze_output_heads: ${.freeze_pretrained_backbone} + optimizer_config: + optimizer: + lr: 1e-3 + aux_optimizer: + lr: 1e-4 + # Retained identities/internal bonds are fixed; scaffold coordinates are + # denoised and supervised, matching the original scaffold training protocol. + exclude_scaffold_from_sub_loss: true + exclude_scaffold_from_x_loss: false + # Let scaffold coordinates move during integration. Training-only 0.1 A + # jitter is configured in the scaffold data preset; inference adds none. + freeze_keep_positions: false + # Keep input formal charges and exclude them from the charge loss. Set false + # to predict scaffold charges and supervise them during fine-tuning, separately + # from the atom-type/internal-bond exclusion above. + freeze_keep_charges: true + + # Override the model target to the scaffold-aware cross-attn wrapper. + model: + _target_: morph.model.finetune.scaffold.ScaffoldConditionalDiTBackboneWithHeads + _recursive_: false + + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + + backbone_model_args: ${model.backbone} + + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + + # Cross-attention context encoder (option 2). Required because the backbone + # below has use_cross_attn: true. For option 1 (node+edge only), set + # backbone.use_cross_attn=false AND null this (the two must agree). + xattn_encoder_args: ${model.xattn_encoder} + # Geometric correspondence prior (query atom ↔ co-located scaffold token). + use_distance_bias: true + +# Cross-attention context encoder (option 2): encodes the scaffold subgraph of +# mol_t (extracted via filter_nodes) into context tokens. ctx_dim must match +# model.backbone.ctx_dim. +# Node/edge conditioning is NOT configured here — the wrapper builds those directly +# from backbone.d_model / backbone.in_edge_nf. Null this (and set +# backbone.use_cross_attn=false) for node+edge-only scaffold conditioning. +xattn_encoder: + _target_: morph.model.finetune.base.LightweightContextEncoder + num_atom_types: ${len:${data.vocab.atom_tokens}} + num_charges: ${len:${data.vocab.charge_tokens}} + atom_embed_dim: 64 + charge_embed_dim: 16 + pos_embed_dim: 32 + ctx_dim: ${model.backbone.ctx_dim} + num_attn_layers: 4 + nhead: 8 + mlp_ratio: 4.0 + dropout: 0.0 + # Edge-based pair bias on the encoder's self-attention (RBF distance + + # edge-type of the scaffold's own bonds) so tokens mix with geometry/topology + # awareness, like the DiT backbone. num_edge_types/edge_embed_dim only used here. + use_pair_bias: true + num_edge_types: ${len:${data.vocab.edge_tokens}} + edge_embed_dim: 32 + num_rbf: 32 + rbf_dmax: 5.0 + pair_bias_hidden_dim: 32 + +# Override the backbone to use ConditionalDiTBackbone. +backbone: + _target_: morph.model.backbones.conditional_dit.ConditionalDiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + ctx_dim: 128 + use_cross_attn: true # cross-attention (cond_tokens) port + # Per-node conditioning: scaffold membership (which atoms to keep). + use_node_cond: true + # Per-edge conditioning: the scaffold encoder emits edge_cond at in_edge_nf. + use_edge_cond: true + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/scaffold_pocket_finetune.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/scaffold_pocket_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6a2959d18d2d8298c680195a3e873b2d2b867770 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/model/finetune/scaffold_pocket_finetune.yaml @@ -0,0 +1,27 @@ +defaults: + - /model/finetune/pocket_finetune@_here_ + - _self_ + +lora: + enabled: true + +module: + _target_: morph.model.finetune.scaffold.LightningModuleRatesScaffoldFinetune + freeze_pretrained_backbone: true + freeze_output_heads: false + optimizer_config: + optimizer: + lr: 1e-3 + aux_optimizer: + lr: 1e-4 + exclude_scaffold_from_sub_loss: true + exclude_scaffold_from_x_loss: false + freeze_keep_positions: false + freeze_keep_charges: true + model: + _target_: morph.model.finetune.pocket.ScaffoldPocketConditionalDiTBackboneWithHeads + +backbone: + use_node_cond: true + use_edge_cond: true + use_cross_attn: true diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/optimizer/muon_aux_adam.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/optimizer/muon_aux_adam.yaml new file mode 100644 index 0000000000000000000000000000000000000000..94cf30139d2d0799df91519152dc7a18c6380355 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/optimizer/muon_aux_adam.yaml @@ -0,0 +1,39 @@ +# @package model.module.optimizer_config +# Muon + auxiliary AdamW preset for the molecular DiT. Per-run overrides +# go on ``model.module.optimizer_config``: +# model.module.optimizer_config.optimizer.lr=0.005 ... +implementation: torch + +optimizer: + # no _target_ -> triggers Muon path in configure_optimizers + # The RTX 4090 Euler template selects native-arithmetic CUDA graph replay. + execution: ${oc.env:MORPH_MUON_EXECUTION,native} + lr: 0.01 + momentum: 0.95 + weight_decay: 0.0 + nesterov: true + ns_steps: 5 + eps: 1e-7 + adjust_lr_fn: original + +aux_optimizer: + lr: 3e-4 + betas: [0.9, 0.95] + eps: 1e-10 + weight_decay: 0.0 + +# Lightning's two-optimizer path uses manual optimization, so clipping is +# configured here and applied once to the full model gradient. +gradient_clip_val: 1.0 +gradient_clip_algorithm: norm + +scheduler: + _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR + # configure_optimizers injects each native optimizer here. + warmup_steps: 2000 # Typically 2-5% of total_steps + total_steps: 200000 # The total number of training iterations + min_lr_fraction: 0.05 # Decays to 5% of each optimizer's peak LR + last_epoch: -1 + +interval: step +monitor: null diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/beta.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/beta.yaml new file mode 100644 index 0000000000000000000000000000000000000000..282746a14cd54cfa9b5560340f90cde6d8762f2f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/beta.yaml @@ -0,0 +1,3 @@ +_target_: morph.flow_matching.schedules.BetaSchedule +k_alpha: 1.0 +k_beta: 1.5 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/cubic.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/cubic.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fd5cf2d9e5dcb3213e5f25acddbf86209620219a --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/cubic.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.CubicSchedule diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/fast_power.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/fast_power.yaml new file mode 100644 index 0000000000000000000000000000000000000000..21757bb0f0037500dc106d10f3a01b1fd4655e22 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/fast_power.yaml @@ -0,0 +1,3 @@ +# Default kappa schedule: FastPowerSchedule with beta=2.5 +_target_: morph.flow_matching.schedules.FastPowerSchedule +beta: 2.0 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/linear.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/linear.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f63c2e226275c32dc406db96f21c3aa488054a80 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/linear.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.LinearSchedule diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/smoothstep.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/smoothstep.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ed29d5013930dafd40a1090a40d08b638976e3ba --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/schedule/smoothstep.yaml @@ -0,0 +1,2 @@ +_target_: morph.flow_matching.schedules.SmoothstepSchedule +shift: 0.8 diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/trainer/trainer.yaml b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/trainer/trainer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a894707dd858be394921733728c16e523ed8810b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/configs/trainer/trainer.yaml @@ -0,0 +1,37 @@ +## Put your trainer args here +trainer: + deterministic: warn + devices: auto + accelerator: auto + #precision: 32 + strategy: ddp_find_unused_parameters_false + precision: "bf16-mixed" + max_epochs: 3000 + # Effective global batch = devices * batch_size.train * accumulation. The + # native optimizer mixin handles this because it steps Muon + AdamW. + accumulate_grad_batches: 1 + # A validation batch performs full generative integration, so Lightning's + # pre-training sanity pass is expensive and does not exercise optimization. + num_sanity_val_steps: 0 + # Muon + AdamW use Lightning manual optimization; clipping is configured in + # configs/optimizer/muon_aux_adam.yaml and applied to both branches together. + gradient_clip_val: null + gradient_clip_algorithm: norm + + # Validation performs full generative integration. The established QM9 + # training recipes use this cadence so sampling does not dominate fitting. + check_val_every_n_epoch: 50 + # profiler: simple + + # Fixed GLOBAL validation population. run.py converts this to a per-rank + # limit, giving the same 4096 molecules on 1, 2 or 4 GPUs. + num_val_samples: 4096 + # Explicit profiling escape hatch; a non-null value takes precedence over + # num_val_samples and is interpreted by Lightning per rank. + limit_val_batches: null + limit_predict_batches: 4 + +do_train: true +do_validate: true +do_predict: true +checkpoint_path: null diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/pyproject.toml b/geom/scaffold_murcko_substituent_lora_r16/runtime/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..fe05aa61e5ca6b28fa46e4ad30027ee0d5ce2042 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/pyproject.toml @@ -0,0 +1,139 @@ +[project] +name = "morph" +version = "0.1.0" +description = "Flow-matching molecular generation and scaffold decoration with a molecular DiT" +readme = "README.md" +keywords = ["molecular-generation", "flow-matching", "scaffold-decoration"] +requires-python = ">=3.11" +dependencies = [ + "click>=8.3.1", + "hydra-core>=1.3.2", + "huggingface-hub>=1.0,<2", + "lightning>=2.5.5", + "omegaconf>=2.3.0", + "numpy>=2.0", + "pandas>=2.3.3", + "rdkit>=2025.9.1", + "scipy>=1.15.0", + # Pre-release allowed: the NVIDIA 25.10 container ships a CUDA-enabled + # 2.9.0a0 build. Preserve that container build instead of replacing it with + # a mismatched PyPI Torch/torchvision pair. + "torch>=2.9.0a0, <2.9.1", + "torch-geometric>=2.7.0", + "torchmetrics>=1.8.0", + "wandb>=0.22.2", + "posebusters>=0.6.0", + "matplotlib>=3.10.8", + "lmdb>=2.2.0", + "py3dmol>=2.5.4", + "ipykernel>=7.2.0", + "ase>=3.28.0", +] + +[dependency-groups] +dev = ["pytest>=9.1.1", "ruff>=0.16.5"] + +[project.optional-dependencies] +flash-attn = ["flash-attn>=2.8.3, <2.8.4"] +crossdocked = ["gdown>=5.2.0"] +scaffold = ["reinvent-chemistry==0.0.51"] + +[project.scripts] +morph = "morph.cli:cli" + +[tool.uv.extra-build-dependencies] +flash-attn = [{ requirement = "torch", match-runtime = true }] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/morph"] + +[tool.hatch.build.targets.wheel.force-include] +"configs" = "morph/configs" + +[tool.ruff] +# Allow lines to be as long as 88. +line-length = 88 +# Keep attributed third-party implementations close to their upstream sources. +extend-exclude = ["src/morph/_vendor"] + +[tool.ruff.format] +docstring-code-format = true + +# Correctness, readability, and public API documentation. +[tool.ruff.lint] +# consider selecting "ISC", for implicit string concat +# consider selecting "T201", for print in production code +select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "D", # pydocstyle + "E", # pycodestyle error + "E501", # line too long + "EXE", # flake8-executable + "F", # pyflakes + "FLY", # flynt + "I", # isort + "ICN", # flake8-import-conventions + "PD", # pandas-vet + "PERF", # perflint + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PYI", # flakes8-pyi + "Q", # flake8-quotes + "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "SIM", # flake8-simplify + "SLOT", # flakes8-slot + "TCH", # flake8-type-checking + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "W", # pycodestyle warning + "W505", # line too long + "YTT", # flake8-2020 +] +# also consider ignoring "C901", for high mccabe function complexity +# also consider ignoring "ERA001", for found commented out code +ignore = [ + "EXE002", # shebang is not a python interpreter + "RUF012", # missing classvar type + "RUF013", # Optional prohibited by PEP484 + "B006", # Do not use mutable data structures for argument defaults + "ANN003", # missing type annotations for kwargs + "B019", # Use of functools.lru_cache on methods can lead to memory leaks + "C408", # unnecessary-collection-call + "COM812", # trailing comma missing + "D205", # 1 blank line required between summary line and description + "D415", # first line of docstring must end with punctuation + "DTZ005", # use of datetime.now() without timezone + "E731", # do not assign a lambda expression, use a def + "EM", # flake8-errmsg + "FBT001", # Boolean positional argument in function + "FBT002", # Boolean keyword argument in function + "NPY002", # TODO replace legacy np.random.seed + "PLR", # pylint refactor + "PLW2901", # Outer for loop variable overwritten by inner assignment target + "PT006", # pytest-parametrize-names-wrong-type + "PT011", # pytest-raises-too-broad + "PT013", # pytest-incorrect-pytest-import + "PT019", # pytest-fixture-param-without-value + "PTH", # prefer Path to os.path + "S301", # pickle can be unsafe + "S310", # url open functions can be unsafe + "TRY003", # long exception messages not defined in the exception class itself + "UP015", # unnecessary "r" in open call + "D107", # docstring for __init__ + "D212", # remove whitespace after opening quotes + "D202", # no blank lines after docstring + "D301", + "D100", + "D103", +] +pydocstyle.convention = "google" + +[env] +PROJECT_ROOT = "." diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/run.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/run.py new file mode 100644 index 0000000000000000000000000000000000000000..89b4ad887caf59c38f433b7cd297802e769a94c8 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/run.py @@ -0,0 +1,277 @@ +import os +from datetime import timedelta + +import hydra +import omegaconf +import pytorch_lightning as pl +import torch +from omegaconf import DictConfig, OmegaConf +from pytorch_lightning.callbacks import LearningRateMonitor +from pytorch_lightning.strategies import DDPStrategy +from rdkit import Chem + +import morph.config # noqa: F401 (import registers OmegaConf resolvers) +from morph.dataset.core.representation import ( + Representation, + validate_representation, +) +from morph.model.build import ( + build_model_metrics, + build_model_module, + prepare_model_data, +) +from morph.utils.run_config import ( + ManualAccumulationWandbLogger, + bootstrap_run_id, + build_callbacks, + configure_manual_gradient_accumulation, + configure_runtime, + gather_rank_outputs, + validation_batches_per_rank, +) + + +def setup(cfg: DictConfig, extra_callbacks=None): + """Prepare data, construct the model, and attach training infrastructure.""" + configure_runtime(seed=int(cfg.get("seed", 42)), diagnostics=True) + OmegaConf.set_struct(cfg, False) + + # Validate the run's representation against the chosen dataset's class-level + # CAPABILITIES before doing any data work. Imports the class only — no + # instantiation, no I/O. + representation = Representation(cfg.representation) + dataset_cls = hydra.utils.get_class(cfg.data.datamodule.datasets.train._target_) + validate_representation(dataset_cls.CAPABILITIES, representation) + + # Instantiate preprocessing to compute distributions from training dataset + hydra.utils.log.info("Instantiating preprocessing...") + world_size = int(os.environ.get("WORLD_SIZE", "1")) + if world_size > 1: + artifact_keys = ( + "atom_tokens_path", + "edge_tokens_path", + "charge_tokens_path", + "distributions_path", + ) + missing = [ + str(cfg.data.preprocessing[key]) + for key in artifact_keys + if not os.path.exists(str(cfg.data.preprocessing[key])) + ] + if missing: + raise RuntimeError( + "Preprocessing artifacts are missing in a distributed run. " + "Run `python scripts/preprocess.py " + f"{cfg.data.dataset_name}` once before torchrun. Missing: {missing}" + ) + preprocessing = hydra.utils.instantiate(cfg.data.preprocessing) + + model_data = prepare_model_data( + cfg, preprocessing.vocab, preprocessing.distributions + ) + vocab = model_data.vocab + + hydra.utils.log.info( + f"Preprocessing complete.\n" + f"Found {len(vocab.atom_tokens)} atom tokens: {vocab.atom_tokens}\n" + f"Found {len(vocab.edge_tokens)} edge tokens: {vocab.edge_tokens}\n" + f"Found {len(vocab.charge_tokens)} charge tokens: {vocab.charge_tokens}" + ) + + hydra.utils.log.info("Distributions computed from training dataset.") + + # Instantiate datamodule + hydra.utils.log.info(f"Instantiating <{cfg.data.datamodule._target_}>") + datamodule: pl.LightningDataModule = hydra.utils.instantiate( + cfg.data.datamodule, + _recursive_=False, + vocab=vocab, + distributions=model_data.prior, + noise_distributions=model_data.empirical, + ) + # Build the training/validation datasets needed for metrics and fitting. + # Lightning calls setup("fit") again, so the datamodule keeps this idempotent. + datamodule.setup("fit") + datamodule.configure_model(cfg) + + # Novelty uses the canonical SMILES sidecars written during preprocessing. + base_dataset = datamodule.train_dataset.base_dataset + train_smiles = ( + base_dataset.get_all_smiles() + if hasattr(base_dataset, "get_all_smiles") + else None + ) + metrics = build_model_metrics( + model_data, train_smiles=train_smiles, train_smiles_are_canonical=True + ) + hydra.utils.log.info(f"Instantiating <{cfg.model.module._target_}>") + module = build_model_module(cfg, model_data, metrics=metrics) + + module.compile() + + # Setup logging and callbacks. A disabled logging config avoids constructing + # a WandB logger at all (useful for smoke tests and fully local runs). + logging_kwargs = OmegaConf.to_container(cfg.logging, resolve=True) + logging_enabled = bool(logging_kwargs.pop("enabled", True)) + requested_accumulation = int(cfg.trainer.trainer.get("accumulate_grad_batches", 1)) + logger_accumulation = ( + requested_accumulation if not module.automatic_optimization else 1 + ) + logger = ( + ManualAccumulationWandbLogger( + **logging_kwargs, accumulation_steps=logger_accumulation + ) + if cfg.trainer.do_train and logging_enabled + else False + ) + callbacks = build_callbacks(cfg) + # LearningRateMonitor refuses to run when there is no logger; only + # attach it when we actually have one (training runs with W&B). + if logger: + callbacks.append(LearningRateMonitor(logging_interval="step")) + if extra_callbacks: + callbacks.extend(extra_callbacks) + + # Instantiate trainer. Multi-GPU: swap the string ``ddp*`` preset for an + # explicit DDPStrategy so we can drop per-step buffer broadcasts (every + # registered buffer -- rotary freqs, RBF centres, loss weights -- is an + # init-time constant, identical across ranks) and use bucket-view gradients. + # Both cut DDP comm at zero correctness cost. + trainer_kwargs = OmegaConf.to_container(cfg.trainer.trainer, resolve=True) + configure_manual_gradient_accumulation(module, trainer_kwargs) + if not module.automatic_optimization: + hydra.utils.log.info( + "Manual optimizer gradient accumulation: " + f"{requested_accumulation} microbatches per update" + ) + + # ``limit_val_batches`` is applied independently on every DDP rank. Derive + # it from one global sample-count setting so a validation curve has the same + # statistical resolution on 1, 2 or 4 GPUs. An explicit non-null batch limit + # remains an escape hatch for profiling scripts. + num_val_samples = trainer_kwargs.pop("num_val_samples", None) + if trainer_kwargs.get("limit_val_batches") is None: + trainer_kwargs.pop("limit_val_batches", None) + if num_val_samples is not None: + val_batches = validation_batches_per_rank( + int(num_val_samples), + int(cfg.data.datamodule.batch_size.val), + world_size, + ) + trainer_kwargs["limit_val_batches"] = val_batches + hydra.utils.log.info( + f"Validation population: {int(num_val_samples)} global samples " + f"({val_batches} batches per rank)" + ) + if world_size > 1 and str(trainer_kwargs.get("strategy", "")).startswith("ddp"): + trainer_kwargs["strategy"] = DDPStrategy( + find_unused_parameters=False, + broadcast_buffers=False, + gradient_as_bucket_view=True, + timeout=timedelta(seconds=int(os.environ.get("MORPH_DDP_TIMEOUT", "1800"))), + ) + trainer = pl.Trainer( + logger=logger, + callbacks=callbacks, + **trainer_kwargs, + ) + + return module, datamodule, trainer + + +def train(module, datamodule, trainer, ckpt_path=None): + trainer.fit( + module, + datamodule=datamodule, + ckpt_path=ckpt_path, + ) + + +def validate(module, datamodule, trainer, ckpt_path=None): + trainer.validate( + module, + datamodule=datamodule, + ckpt_path=ckpt_path, + ) + + +def predict(module, datamodule, trainer, ckpt_path=None): + + predictions = trainer.predict( + module, + datamodule=datamodule, + ckpt_path=ckpt_path, + ) + + def _batch_dicts(items): + for item in items or []: + if isinstance(item, dict): + yield item + elif isinstance(item, (list, tuple)): + yield from _batch_dicts(item) + + predictions = list(_batch_dicts(predictions)) + gathered_predictions = gather_rank_outputs(predictions) + if gathered_predictions is None: + return predictions + predictions = gathered_predictions + + all_valid_mols = [] + all_invalid_mols = [] + all_invalid_mols_rdkit = [] + + for batch_output in predictions: + all_valid_mols.extend(batch_output["valid_mols"]) + all_invalid_mols.extend(batch_output["invalid_mols"]) + all_invalid_mols_rdkit.extend(batch_output["invalid_mols_rdkit"]) + + if not trainer.is_global_zero: + return predictions + + torch.save(all_valid_mols, "valid_mols.pt") + torch.save(all_invalid_mols, "invalid_mols.pt") + + writer = Chem.SDWriter("invalid_molecules.sdf") + + for mol in all_invalid_mols_rdkit: + # Optional: Catch sanitization errors during writing just in case + if mol is None: + continue + + try: + writer.write(mol) + except Exception as e: + print(f"Could not write molecule to SDF: {e}") + + writer.close() + return predictions + + +@hydra.main( + config_path="configs", + config_name="default", + version_base="1.1", +) +def main(cfg: omegaconf.DictConfig): + ckpt_path = cfg.trainer.checkpoint_path + + module, datamodule, trainer = setup(cfg) + + if cfg.trainer.do_train: + train(module, datamodule, trainer, ckpt_path) + final_checkpoint = cfg.trainer.get("final_checkpoint_path") + if final_checkpoint and not trainer.fast_dev_run: + trainer.save_checkpoint(str(final_checkpoint)) + # The fitted module already contains the resumed-and-updated weights. + # Reusing the original resume path here would silently validate/predict + # the pre-fit checkpoint instead. + ckpt_path = None + if cfg.trainer.do_validate: + validate(module, datamodule, trainer, ckpt_path) + if cfg.trainer.do_predict: + predict(module, datamodule, trainer, ckpt_path) + + +if __name__ == "__main__": + bootstrap_run_id() + main() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..320a78e02ba300f6433f6b0131e073b0517f6353 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__init__.py @@ -0,0 +1 @@ +"""morph — flow-matching generative modeling for molecules and materials.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__main__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..bfc34c1fb7a9b7c2a1f0ec010fc40eb07495c7b1 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__main__.py @@ -0,0 +1,6 @@ +"""Enable ``python -m morph ...`` (works without installing the console script).""" + +from morph.cli import cli + +if __name__ == "__main__": + cli() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc2a4609e7def8fd9e7a2f480231a568b1411c38 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__pycache__/config.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..926175d74f4de5ce60fc393f8598eb863b0b27c3 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/__pycache__/config.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..24bba35366c470be8093d2f9f133cb384a991740 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__init__.py @@ -0,0 +1 @@ +"""Third-party reference code kept separate from Morph's public API.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7066d3733fe136a5767e6e860af5962c85acd1c4 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__pycache__/muon.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__pycache__/muon.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02ec05da91e5935b86b11420804928cc9c6452df Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/__pycache__/muon.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/geom_drugs_preprocessing.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/geom_drugs_preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..b6037a4822dc690f1913505d661da1e11a488d96 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/geom_drugs_preprocessing.py @@ -0,0 +1,296 @@ +""" +Vendored from the repository accompanying the following paper. + +@article{nikitin2025geom, + title={GEOM-Drugs Revisited: Toward More Chemically Accurate Benchmarks for 3D Molecule Generation}, + author={Nikitin, Filipp and Dunn, Ian and Koes, David Ryan and Isayev, Olexandr}, + journal={arXiv preprint arXiv:2505.00169}, + year={2025} +} + +GEOM-Drugs Dataset Processing and Validation Module + +This module implements the complete preprocessing pipeline for the GEOM-Drugs dataset, +including molecular sanitization, topology validation, and valency analysis. It addresses +chemical accuracy issues in the original dataset by applying rigorous validation criteria. + +Key Processing Steps: +1. Molecular sanitization using RDKit with full kekulization +2. Topology validation using distance-based bond length checking +3. Fragment filtering to remove disconnected molecules +4. Valency dictionary construction for stability analysis + +Topology Validation Strategy: +The module uses covalent radii and tolerance-based distance checking to validate +that molecular geometries are chemically reasonable. This catches issues like: +- Unrealistic bond lengths that would indicate poor conformer generation +- Overlapping atoms or impossible geometries +- Inconsistent connectivity across conformers + +Distance Tolerance Logic: +- Uses element-specific covalent radii as reference distances +- Applies 40% tolerance by default (configurable) +- Validates all conformers must have consistent topology +- Removes molecules with any invalid conformers +""" + +import json +import os +import pickle +from pathlib import Path + +import numpy as np +from rdkit import Chem +from rdkit.Chem import SanitizeFlags + +# Covalent radii dictionary for topology validation +# Values in Angstroms, used to determine expected bond lengths +covalent_radii = { + 1: 0.31, # Hydrogen + 6: 0.76, # Carbon + 7: 0.71, # Nitrogen + 8: 0.66, # Oxygen + 9: 0.57, # Fluorine + 15: 1.07, # Phosphorus + 16: 1.05, # Sulfur + 17: 1.02, # Chlorine + 35: 1.20, # Bromine + 53: 1.39 # Iodine +} + + +def get_reference_distance_matrix(adjacency_matrix, numbers): + """ + Calculate expected bond lengths based on covalent radii. + + """ + adjacency_mask = (adjacency_matrix > 0).astype(int) + # Get covalent radii for each atom, default to 1.5 Å for unknown elements + rads = np.array([covalent_radii.get(i, 1.5) for i in numbers]) + # Sum of covalent radii gives expected bond length + return rads[:, np.newaxis] + rads[np.newaxis, :] + + +def calculate_distance_map(coordinates): + # Broadcasting to compute all pairwise distances efficiently + diff = coordinates[:, :, np.newaxis, :] - coordinates[:, np.newaxis, :, :] + return np.linalg.norm(diff, axis=-1) + + +def check_topology(adjacency_matrix, numbers, coordinates, tolerance=0.4): + """ + Validate molecular topology using distance-based bond length checking. + + Validation Logic: + 1. Calculate expected bond lengths from covalent radii + 2. Measure actual bond lengths in conformer geometries + 3. Check if actual lengths are within tolerance of expected values + 4. Require ALL bonded pairs in ALL conformers to pass validation + + Args: + adjacency_matrix (np.ndarray): Molecular connectivity [n_atoms, n_atoms] + numbers (np.ndarray): Atomic numbers [n_atoms] + coordinates (np.ndarray): Conformer coordinates [n_conformers, n_atoms, 3] + tolerance (float): Relative tolerance for bond length validation (default: 0.4 = 40%) + + Returns: + np.ndarray: Boolean array indicating which conformers pass validation + """ + # Create mask for bonded atom pairs only + adjacency_mask = (adjacency_matrix > 0).astype(int) + + # Get expected distances and mask to bonded pairs only + ref_dist = get_reference_distance_matrix(adjacency_matrix, numbers) * adjacency_mask + + # Calculate actual distances and mask to bonded pairs only + data_dist = calculate_distance_map(coordinates) * adjacency_mask + + # Check if actual distances are within tolerance of expected distances + # tolerance is relative: actual_dist <= expected_dist * (1 + tolerance) + diffs = np.abs(data_dist - ref_dist[np.newaxis, :, :]) <= ( + ref_dist[np.newaxis, :, :] * tolerance) + + # All bonded pairs in all conformers must pass validation + return diffs.all(axis=(1, 2)) + + +def process_molecule(mol): + """ + Sanitize and kekulize a molecular structure with strict validation. + + Sanitization Steps Applied: + - SANITIZE_ALL: Complete RDKit sanitization pipeline + - Kekulization with clearAromaticFlags=True: Ensures proper aromatic/Kekulé form + + Fragment Filtering: + - Rejects molecules with multiple disconnected fragments + - Ensures analysis focuses on single molecular entities + + Args: + mol (rdkit.Chem.Mol): Input RDKit molecule object + + Returns: + rdkit.Chem.Mol or None: Sanitized molecule or None if processing fails + """ + try: + # Create a copy to avoid modifying the original + mol = Chem.Mol(mol) + + # Apply full RDKit sanitization pipeline + Chem.SanitizeMol(mol, sanitizeOps=SanitizeFlags.SANITIZE_ALL) + + # Kekulize with aromatic flag clearing for consistent valency analysis + # This ensures aromatic systems are properly handled in stability calculations + Chem.Kekulize(mol, clearAromaticFlags=True) + + except Exception: + # Any sanitization failure results in molecule rejection + return None + + # Reject molecules with multiple fragments (disconnected components) + if len(Chem.GetMolFrags(mol)) > 1: + return None + + return mol + + +def validate_topology(mol): + """ + Validate that all conformers of a molecule have reasonable geometries. + """ + # Extract molecular connectivity + adjacency_matrix = Chem.GetAdjacencyMatrix(mol) + + # Get atomic numbers for covalent radius calculations + numbers = np.array([atom.GetAtomicNum() for atom in mol.GetAtoms()]) + + # Extract conformer coordinates + conformers = mol.GetConformers() + if not conformers: + return False # No conformers = invalid + + coordinates = np.array([conf.GetPositions() for conf in conformers]) + + # Apply topology validation to all conformers + return check_topology(adjacency_matrix, numbers, coordinates).all() + + +def process_geom_drugs(input_folder, output_folder): + """ + Complete processing pipeline for GEOM-Drugs dataset. + + Processing Pipeline: + 1. Load raw pickle files from MiDi GEOM-Drugs splits + 2. Validate SMILES strings can be parsed by RDKit + 3. Apply molecular sanitization and kekulization + 4. Perform topology validation on all conformers + 5. Filter out molecules with invalid geometries or connectivity + 6. Build comprehensive valency dictionary for stability analysis + 7. Save cleaned data and analysis results + + Args: + input_folder (str): Path to directory containing raw GEOM-Drugs pickle files + output_folder (str): Path to save processed dataset and analysis files + + Output Files: + - {train,val,test}_data.pickle: Cleaned molecular datasets + - valency_dict.json: Comprehensive valency lookup table + + + """ + os.makedirs(output_folder, exist_ok=True) + valency_dict = {} + + # Process each data split independently + for split in ["test_data.pickle", "train_data.pickle", "val_data.pickle"]: + input_path = Path(input_folder) / split + output_path = Path(output_folder) / split + + if not input_path.exists(): + print(f"Skipping {input_path}, file not found.") + continue + + # Load raw dataset + with open(input_path, "rb") as f: + data = pickle.load(f) + + # Initialize statistics tracking + initial_size = len(data) + conformer_count = 0 + initial_conformer_count = 0 + conformer_counts = [] + + # Process molecules with in-place filtering + i = 0 + while i < len(data): + smiles, mols = data[i] + + # Validate SMILES can be parsed (sanity check) + reference_mol = Chem.MolFromSmiles(smiles) + if reference_mol is None: + del data[i] + continue + + initial_conformer_count += len(mols) + + # Apply molecular sanitization to all conformers + sanitized_mols = [process_molecule(mol) for mol in mols] + sanitized_mols = [mol for mol in sanitized_mols if mol is not None] + + # Require at least one valid conformer and all must pass topology validation + if not sanitized_mols or not all(validate_topology(mol) for mol in sanitized_mols): + del data[i] + continue + + # Build valency dictionary from validated molecules + # This creates an empirical valency table from the cleaned dataset + for mol in sanitized_mols: + for atom in mol.GetAtoms(): + element = atom.GetSymbol() + charge = str(atom.GetFormalCharge()) + valency = atom.GetExplicitValence() + valency_dict.setdefault(element, {}).setdefault(charge, []) + if valency not in valency_dict[element][charge]: + valency_dict[element][charge].append(valency) + + # Update statistics and store cleaned data + conformer_counts.append(len(sanitized_mols)) + conformer_count += len(sanitized_mols) + data[i] = (smiles, sanitized_mols) + i += 1 + + # Calculate and report processing statistics + final_size = len(data) + removed_molecules = initial_size - final_size + dropped_conformers = initial_conformer_count - sum(conformer_counts) + + print(f"\nProcessed {split}:") + print(f" Molecules saved: {final_size}") + print(f" Molecules removed: {removed_molecules} ({(removed_molecules / initial_size * 100):.2f}%)") + print(f" Kept conformers: {sum(conformer_counts)}") + print(f" Dropped conformers: {dropped_conformers} ({(dropped_conformers / initial_conformer_count * 100):.2f}%)") + + if conformer_counts: + print(f" Conformer count per molecule: min={min(conformer_counts)}, max={max(conformer_counts)}, mean={np.mean(conformer_counts):.2f}") + + # Save cleaned dataset + with open(output_path, "wb") as f: + pickle.dump(data, f) + + # Save comprehensive valency dictionary for stability analysis + valency_output = Path(output_folder) / "valency_dict.json" + with open(valency_output, "w") as f: + json.dump(valency_dict, f, indent=4) + + print(f"\nValency dictionary saved to {valency_output}") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Process GEOM-Drugs dataset.") + parser.add_argument("--input_folder", type=str, required=True, help="Path to midi_split folder.") + parser.add_argument("--output_folder", type=str, required=True, help="Path to save filtered results.") + args = parser.parse_args() + + process_geom_drugs(args.input_folder, args.output_folder) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/muon.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/muon.py new file mode 100644 index 0000000000000000000000000000000000000000..92e596caf3d6194a5829602568048ea0680f6893 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/_vendor/muon.py @@ -0,0 +1,396 @@ +"""Vendored legacy Muon implementation retained for comparison benchmarks.""" + +import os + +import torch +import torch.distributed as dist + +# Newton-Schulz iteration count for Muon's orthogonalization. Batch-independent +# cost paid every optimizer step (the dominant fixed per-step overhead in a +# many-small-steps regime). Default 5; lower (3, even 2) trades orthogonalization +# accuracy for cheaper steps -- validate training quality before adopting. +_DEFAULT_NS_STEPS = int(os.environ.get("MORPH_MUON_NS_STEPS", "5")) + + +def _shape_buckets(params): + """Group parameters so each distributed all-gather has uniform shapes.""" + buckets = {} + for param in params: + key = (tuple(param.shape), param.dtype, param.device) + buckets.setdefault(key, []).append(param) + return buckets.values() + + +def zeropower_via_newtonschulz5(G, steps: int): + """ + Newton-Schulz iteration to compute the zeroth power / orthogonalization of G. We opt to use a + quintic iteration whose coefficients are selected to maximize the slope at zero. For the purpose + of minimizing steps, it turns out to be empirically effective to keep increasing the slope at + zero even beyond the point where the iteration no longer converges all the way to one everywhere + on the interval. This iteration therefore does not produce UV^T but rather something like US'V^T + where S' is diagonal with S_{ii}' ~ Uniform(0.5, 1.5), which turns out not to hurt model + performance at all relative to UV^T, where USV^T = G is the SVD. + """ + assert ( + G.ndim >= 2 + ) # batched Muon implementation by @scottjmaddox, and put into practice in the record by @YouJiacheng + a, b, c = (3.4445, -4.7750, 2.0315) + X = G.bfloat16() + if G.size(-2) > G.size(-1): + X = X.mT + + # Ensure spectral norm is at most 1 + X = X / (X.norm(dim=(-2, -1), keepdim=True) + 1e-7) + # Perform the NS iterations + for _ in range(steps): + A = X @ X.mT + B = ( + b * A + c * A @ A + ) # quintic computation strategy adapted from suggestion by @jxbz, @leloykun, and @YouJiacheng + X = a * X + B @ X + + if G.size(-2) > G.size(-1): + X = X.mT + return X + + +def muon_update_from_momentum( + grad, momentum, beta=0.95, ns_steps=_DEFAULT_NS_STEPS, nesterov=True +): + update = grad.lerp_(momentum, beta) if nesterov else momentum + if update.ndim == 4: # for the case of conv filters + update = update.view(len(update), -1) + update = zeropower_via_newtonschulz5(update, steps=ns_steps) + update *= max(1, grad.size(-2) / grad.size(-1)) ** 0.5 + return update + + +def muon_update(grad, momentum, beta=0.95, ns_steps=_DEFAULT_NS_STEPS, nesterov=True): + momentum.lerp_(grad, 1 - beta) + return muon_update_from_momentum(grad, momentum, beta, ns_steps, nesterov) + + +class Muon(torch.optim.Optimizer): + """ + Muon - MomentUm Orthogonalized by Newton-schulz + + https://kellerjordan.github.io/posts/muon/ + + Muon internally runs standard SGD-momentum, and then performs an orthogonalization post- + processing step, in which each 2D parameter's update is replaced with the nearest orthogonal + matrix. For efficient orthogonalization we use a Newton-Schulz iteration, which has the + advantage that it can be stably run in bfloat16 on the GPU. + + Muon should only be used for hidden weight layers. The input embedding, final output layer, + and any internal gains or biases should be optimized using a standard method such as AdamW. + Hidden convolutional weights can be trained using Muon by viewing them as 2D and then + collapsing their last 3 dimensions. + + Arguments: + lr: The learning rate, in units of spectral norm per update. + weight_decay: The AdamW-style weight decay. + momentum: The momentum. A value of 0.95 here is usually fine. + """ + + def __init__(self, params, lr=0.02, weight_decay=0, momentum=0.95): + defaults = dict(lr=lr, weight_decay=weight_decay, momentum=momentum) + assert ( + isinstance(params, list) + and len(params) >= 1 + and isinstance(params[0], torch.nn.Parameter) + ) + params = sorted(params, key=lambda x: x.size(), reverse=True) + super().__init__(params, defaults) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + params = group["params"] + # DDP gradients are already identical on every rank. Keep the cheap + # momentum recurrence complete on every rank so a rank-zero + # checkpoint contains every parameter's optimizer state; only the + # expensive Newton-Schulz update remains sharded. + momenta, grads = [], [] + for p in params: + if p.grad is None: + p.grad = torch.zeros_like(p) + state = self.state[p] + if "momentum_buffer" not in state: + state["momentum_buffer"] = torch.zeros_like(p) + momenta.append(state["momentum_buffer"]) + grads.append(p.grad) + torch._foreach_lerp_(momenta, grads, 1 - group["momentum"]) + + for bucket in _shape_buckets(params): + params_pad = bucket + [torch.empty_like(bucket[-1])] * ( + (-len(bucket)) % dist.get_world_size() + ) + for base_i in range(0, len(bucket), dist.get_world_size()): + if base_i + dist.get_rank() < len(bucket): + p = bucket[base_i + dist.get_rank()] + state = self.state[p] + update = muon_update_from_momentum( + p.grad, + state["momentum_buffer"], + beta=group["momentum"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + dist.all_gather( + params_pad[base_i : base_i + dist.get_world_size()], + params_pad[base_i + dist.get_rank()], + ) + + return loss + + +class SingleDeviceMuon(torch.optim.Optimizer): + """ + Muon variant for usage in non-distributed settings. + """ + + def __init__(self, params, lr=0.02, weight_decay=0, momentum=0.95): + defaults = dict(lr=lr, weight_decay=weight_decay, momentum=momentum) + super().__init__(params, defaults) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["momentum_buffer"] = torch.zeros_like(p) + update = muon_update( + p.grad, state["momentum_buffer"], beta=group["momentum"] + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + + return loss + + +def adam_update(grad, buf1, buf2, step, betas, eps): + buf1.lerp_(grad, 1 - betas[0]) + buf2.lerp_(grad.square(), 1 - betas[1]) + buf1c = buf1 / (1 - betas[0] ** step) + buf2c = buf2 / (1 - betas[1] ** step) + return buf1c / (buf2c.sqrt() + eps) + + +class MuonWithAuxAdam(torch.optim.Optimizer): + """ + Distributed Muon variant that can be used for all parameters in the network, since it runs an + internal AdamW for the parameters that are not compatible with Muon. The user must manually + specify which parameters shall be optimized with Muon and which with Adam by passing in a + list of param_groups with the `use_muon` flag set. + + The point of this class is to allow the user to have a single optimizer in their code, rather + than having both a Muon and an Adam which each need to be stepped. + + You can see an example usage below: + + https://github.com/KellerJordan/modded-nanogpt/blob/master/records/052525_MuonWithAuxAdamExample/b01550f9-03d8-4a9c-86fe-4ab434f1c5e0.txt#L470 + ``` + hidden_matrix_params = [ + p + for n, p in model.blocks.named_parameters() + if p.ndim >= 2 and "embed" not in n + ] + embed_params = [p for n, p in model.named_parameters() if "embed" in n] + scalar_params = [p for p in model.parameters() if p.ndim < 2] + head_params = [model.lm_head.weight] + + from muon import MuonWithAuxAdam + + adam_groups = [ + dict(params=head_params, lr=0.22), + dict(params=embed_params, lr=0.6), + dict(params=scalar_params, lr=0.04), + ] + adam_groups = [ + dict(**g, betas=(0.8, 0.95), eps=1e-10, use_muon=False) for g in adam_groups + ] + muon_group = dict( + params=hidden_matrix_params, lr=0.05, momentum=0.95, use_muon=True + ) + param_groups = [*adam_groups, muon_group] + optimizer = MuonWithAuxAdam(param_groups) + ``` + """ + + def __init__(self, param_groups): + for group in param_groups: + assert "use_muon" in group + if group["use_muon"]: + group["params"] = sorted( + group["params"], key=lambda x: x.size(), reverse=True + ) + # defaults + group["lr"] = group.get("lr", 0.02) + group["momentum"] = group.get("momentum", 0.95) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "momentum", "weight_decay", "use_muon"] + ) + else: + # defaults + group["lr"] = group.get("lr", 3e-4) + group["betas"] = group.get("betas", (0.9, 0.95)) + group["eps"] = group.get("eps", 1e-10) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "betas", "eps", "weight_decay", "use_muon"] + ) + super().__init__(param_groups, dict()) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + if group["use_muon"]: + params = group["params"] + # See ``Muon.step``: update the inexpensive momentum recurrence + # everywhere so every rank can write a complete checkpoint. + momenta, grads = [], [] + for p in params: + if p.grad is None: + p.grad = torch.zeros_like(p) + state = self.state[p] + if "momentum_buffer" not in state: + state["momentum_buffer"] = torch.zeros_like(p) + momenta.append(state["momentum_buffer"]) + grads.append(p.grad) + torch._foreach_lerp_(momenta, grads, 1 - group["momentum"]) + + for bucket in _shape_buckets(params): + params_pad = bucket + [torch.empty_like(bucket[-1])] * ( + (-len(bucket)) % dist.get_world_size() + ) + for base_i in range(0, len(bucket), dist.get_world_size()): + if base_i + dist.get_rank() < len(bucket): + p = bucket[base_i + dist.get_rank()] + state = self.state[p] + update = muon_update_from_momentum( + p.grad, + state["momentum_buffer"], + beta=group["momentum"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + dist.all_gather( + params_pad[base_i : base_i + dist.get_world_size()], + params_pad[base_i + dist.get_rank()], + ) + else: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["exp_avg"] = torch.zeros_like(p) + state["exp_avg_sq"] = torch.zeros_like(p) + state["step"] = 0 + state["step"] += 1 + update = adam_update( + p.grad, + state["exp_avg"], + state["exp_avg_sq"], + state["step"], + group["betas"], + group["eps"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update, alpha=-group["lr"]) + + return loss + + +class SingleDeviceMuonWithAuxAdam(torch.optim.Optimizer): + """ + Non-distributed variant of MuonWithAuxAdam. + """ + + def __init__(self, param_groups): + for group in param_groups: + assert "use_muon" in group + if group["use_muon"]: + # defaults + group["lr"] = group.get("lr", 0.02) + group["momentum"] = group.get("momentum", 0.95) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "momentum", "weight_decay", "use_muon"] + ) + else: + # defaults + group["lr"] = group.get("lr", 3e-4) + group["betas"] = group.get("betas", (0.9, 0.95)) + group["eps"] = group.get("eps", 1e-10) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "betas", "eps", "weight_decay", "use_muon"] + ) + super().__init__(param_groups, dict()) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + if group["use_muon"]: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["momentum_buffer"] = torch.zeros_like(p) + update = muon_update( + p.grad, state["momentum_buffer"], beta=group["momentum"] + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + else: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["exp_avg"] = torch.zeros_like(p) + state["exp_avg_sq"] = torch.zeros_like(p) + state["step"] = 0 + state["step"] += 1 + update = adam_update( + p.grad, + state["exp_avg"], + state["exp_avg_sq"], + state["step"], + group["betas"], + group["eps"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update, alpha=-group["lr"]) + + return loss diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/cli.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..dbf0e33690827f5c08b58fb80581100b0c0d6b46 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/cli.py @@ -0,0 +1,334 @@ +# CLI subcommands import the scientific stack only when executed. +# ruff: noqa: PLC0415 + +"""``morph`` — molecular generation CLI. + +Run in-process (on a GPU node / via ``srun``): + + uv run --active --env-file .env morph generate -b 16 + # or, without (re)installing the console script: + uv run --active --env-file .env python -m morph generate -b 16 + +Model bundles are downloaded from Hugging Face on demand. +Use ``morph models list`` to inspect variants and available epochs. +""" + +from __future__ import annotations + +import click + + +def _catalog_options(command): + """Share explicit catalog source options across discovery and loading.""" + command = click.option( + "--catalog-repo", help="HF repository hosting catalog.json." + )(command) + command = click.option( + "--catalog-revision", help="Catalog commit, tag or branch (default: main)." + )(command) + return click.option( + "--catalog-file", + type=click.Path(exists=True, dir_okay=False), + help="Local catalog JSON; skips catalog network access.", + )(command) + + +def _get_catalog(repo, revision, path, cache_dir, offline, *, optional=False): + """Resolve an explicit override, or leave default discovery to the loader.""" + from morph.inference import load_catalog + + if optional and repo is None and revision is None and path is None: + return None + return load_catalog( + repo, + revision=revision, + path=path, + cache_dir=cache_dir, + local_files_only=offline, + ) + + +@click.group() +@click.version_option(package_name="morph") +def cli() -> None: + """Morph — molecular generation CLI.""" + + +@cli.group() +def models() -> None: + """Discover and download pretrained model bundles.""" + + +@models.command("list") +@_catalog_options +@click.option( + "--cache-dir", type=click.Path(file_okay=False), help="HF cache directory." +) +@click.option("--offline", is_flag=True, help="Use only the cached catalog.") +def list_models( + catalog_repo, catalog_revision, catalog_file, cache_dir, offline +) -> None: + """Discover models from the Hub catalog without downloading weights.""" + try: + catalog = _get_catalog( + catalog_repo, catalog_revision, catalog_file, cache_dir, offline + ) + except (ValueError, OSError, RuntimeError) as exc: + raise click.ClickException(str(exc)) from exc + + click.echo(f"{'MODEL':24} {'DATASET':12} {'EPOCHS':18} DEFAULT EPOCH") + for spec in catalog.models.values(): + epochs = ", ".join(map(str, spec.epochs)) or "from bundle" + latest = spec.select_epoch() or "from bundle" + click.echo(f"{spec.name:24} {spec.dataset or '-':12} {epochs:18} {latest}") + click.echo(f"Default model: {catalog.default_model or '(none)'}") + source = ( + f"{catalog.repo_id}@{catalog.revision}" + if catalog.repo_id + else str(catalog.path) + ) + click.echo(f"Catalog: {source}") + + +@models.command("download") +@_catalog_options +@click.argument("model_name") +@click.option("--epoch", type=click.IntRange(min=1), help="Completed training epoch.") +@click.option("--subfolder", help="Bundle subfolder inside the repository.") +@click.option( + "--revision", help="HF commit, tag or branch; catalog entries pin a commit." +) +@click.option( + "--filename", help="Checkpoint path relative to the bundle, instead of epoch." +) +@click.option( + "--cache-dir", type=click.Path(file_okay=False), help="HF cache directory." +) +@click.option( + "--local-dir", type=click.Path(file_okay=False), help="Export root directory." +) +@click.option( + "--offline", is_flag=True, help="Use cached files without network access." +) +def download_model( + model_name, + epoch, + subfolder, + revision, + filename, + cache_dir, + local_dir, + offline, + catalog_repo, + catalog_revision, + catalog_file, +) -> None: + """Download one epoch with its config, vocabularies and distributions. + + Auth: hf auth login, or set HF_TOKEN. Prints the local bundle directory. + """ + from morph.inference.hub import download_pretrained + + try: + catalog = _get_catalog( + catalog_repo, + catalog_revision, + catalog_file, + cache_dir, + offline, + optional=True, + ) + bundle = download_pretrained( + model_name, + epoch=epoch, + subfolder=subfolder, + revision=revision, + filename=filename, + catalog=catalog, + cache_dir=cache_dir, + local_dir=local_dir, + local_files_only=offline, + ) + except (ValueError, OSError, RuntimeError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo(str(bundle.directory)) + + +@cli.command() +@_catalog_options +@click.option( + "-b", + "--batch", + "n_mols", + type=click.IntRange(min=1), + default=16, + show_default=True, + help="Number of molecules to generate.", +) +@click.option( + "-o", + "--output", + type=click.Path(), + show_default=True, + default="eval_outputs/denovo.sdf", + help="Output SDF of final-frame molecules (3D coords + SMILES/logP).", +) +@click.option( + "--model", + "model_name", + default=None, + help="Catalog name (morph models list) or Hugging Face owner/repo ID.", +) +@click.option( + "--epoch", type=click.IntRange(min=1), help="Epoch; defaults to latest published." +) +@click.option( + "--subfolder", help="Bundle subfolder inside the repository or local root." +) +@click.option( + "--revision", help="HF commit, tag or branch; catalog entries pin a commit." +) +@click.option( + "--filename", help="Checkpoint path relative to the bundle, instead of epoch." +) +@click.option( + "--cache-dir", type=click.Path(file_okay=False), help="HF cache directory." +) +@click.option( + "--offline", is_flag=True, help="Use cached files without network access." +) +@click.option( + "--device", + type=click.Choice(["auto", "cpu", "cuda"]), + default="auto", + show_default=True, +) +@click.option("--steps", type=click.IntRange(min=1), default=100, show_default=True) +@click.option( + "--ema/--no-ema", + default=True, + show_default=True, + help="Use EMA or current weights.", +) +@click.option( + "--weights-dir", + default=None, + type=click.Path(exists=True, file_okay=False), + help="Local variant bundle or download root; skips Hugging Face entirely.", +) +@click.option( + "--checkpoint", + default=None, + type=click.Path(exists=True, dir_okay=False), + help="Override local weights; requires --weights-dir with matching assets.", +) +@click.option( + "--save-trajs", + is_flag=True, + help="Also dump full trajectories (_trajs.pt).", +) +@click.option("--seed", default=42, show_default=True) +@click.option( + "--batch-size", + type=click.IntRange(min=1), + default=None, + help="Molecules per GPU batch (default: all requested molecules).", +) +def generate( + n_mols, + output, + model_name, + weights_dir, + checkpoint, + save_trajs, + seed, + batch_size, + epoch, + subfolder, + revision, + filename, + catalog_repo, + catalog_revision, + catalog_file, + cache_dir, + offline, + device, + steps, + ema, +) -> None: + """Generate molecules de-novo with an unconditional checkpoint. + + Downloads the selected checkpoint and matching assets on first use. + Uses the saved model config, eager execution, and EMA weights by default. + """ + from pathlib import Path + + import torch + + from morph.inference.generate import record_mol, write_sdf + from morph.inference.hub import load_pretrained + + try: + catalog = _get_catalog( + catalog_repo, + catalog_revision, + catalog_file, + cache_dir, + offline, + optional=True, + ) + model = load_pretrained( + model_name, + epoch=epoch, + subfolder=subfolder, + revision=revision, + filename=filename, + catalog=catalog, + device=device, + weights_dir=weights_dir, + checkpoint=checkpoint, + cache_dir=cache_dir, + local_files_only=offline, + steps=steps, + use_ema=ema, + ) + except (ValueError, OSError, RuntimeError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo( + f"[morph generate] model={model.bundle.spec.name} " + f"ckpt={model.bundle.checkpoint} n={n_mols}" + ) + + trajectories, is_valid, vocab, _coord_std = model.generate( + n_mols=n_mols, + seed=seed, + batch_size=batch_size, + return_traj=save_trajs, + ) + + out = Path(output) + valid_trajectories = [ + traj for traj, valid in zip(trajectories, is_valid, strict=True) if valid + ] + n_written, _ = write_sdf(valid_trajectories, vocab, out) + click.echo(f"wrote {n_written}/{len(trajectories)} attempts -> {out}") + if save_trajs: + tp = out.with_name(out.stem + "_trajs.pt") + torch.save({"trajectories": trajectories, "is_valid": is_valid}, tp) + click.echo(f"wrote trajectories -> {tp}") + + rows = [record_mol(t, vocab) for t in trajectories] + for row, valid in zip(rows, is_valid, strict=True): + row["valid"] = valid + n_valid = sum(is_valid) + n_uniq = len({r["smiles"] for r in rows if r["valid"] and r["smiles"] is not None}) + click.echo(f" valid: {n_valid}/{len(rows)} | unique SMILES: {n_uniq}") + for i, r in enumerate(rows): + click.echo( + f" [{i}] valid={r['valid']} N={r['n_atoms']} " + f"logP={r['logp']} {r['smiles']}" + ) + + +if __name__ == "__main__": + cli() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/config.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/config.py new file mode 100644 index 0000000000000000000000000000000000000000..da5bb482ee9d174a998bdb034503cd5cb02b8741 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/config.py @@ -0,0 +1,23 @@ +"""Shared OmegaConf resolver registration. + +Importing this module registers Morph's custom resolvers. Registration is +idempotent so entry points and libraries can safely import it in any order. +""" + +from omegaconf import OmegaConf + + +def register_resolvers() -> None: + """Register the resolvers used by Morph's Hydra configurations.""" + resolvers = { + "oc.eval": eval, + "len": len, + "if": lambda condition, true, false: true if condition else false, + "eq": lambda left, right: left == right, + } + for name, resolver in resolvers.items(): + if not OmegaConf.has_resolver(name): + OmegaConf.register_new_resolver(name, resolver) + + +register_resolvers() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..10e5dc5a50476b29d2ae1d166f14155adb49730d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/__init__.py @@ -0,0 +1,13 @@ +"""morph dataset layer. + +Organised into three groups so the dependency direction is legible +(``conditional`` -> ``unconditional`` -> ``core``, never upward): + +* :mod:`morph.dataset.core` -- shared infrastructure every dataset builds + on (the ``ChemAtomicData`` / ``ChemBatch`` containers, vocab, + representation, the flow-matching dataset wrapper, the base Lightning + datamodule, preprocessing and property caching). +* :mod:`morph.dataset.unconditional` -- concrete GEOM and QM9 sources, + each exposing a ``FlowMatching*`` variant. +* :mod:`morph.dataset.conditional` -- scaffold-decoration fine-tuning wrappers. +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..252ee71470b5fb731b7d15aea10cffa10a43c80a Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7509fd9b3a353826d75bef7a877a65dcbe818192 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/__init__.py @@ -0,0 +1,8 @@ +"""Conditional / finetuning task wrappers. + +Each subpackage wraps an unconditional source into a paired / conditioned +dataset for a downstream finetuning task: + +* :mod:`morph.dataset.conditional.scaffold` -- bare-scaffold decoration. +* :mod:`morph.dataset.conditional.pocket` -- CrossDocked ligand/pocket pairs. +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/README.md b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/README.md new file mode 100644 index 0000000000000000000000000000000000000000..86c9659c4de669dcd8dec970009d196c5b53baf6 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/README.md @@ -0,0 +1,354 @@ +# CrossDocked pocket data + +Dataset and preprocessing port from `maltefranke/morph` remote `main` at +`f81996ace95ee45c2e1d23332252d38137b3a918` (August 30, 2026). This package supplies +ligand targets and protein-pocket context, with a fine-tuning pipeline that mixes +pocket examples and unconditional pretraining replay. + +For the DiffDec scaffold-in-pocket evaluation functions and standalone sampling +script, see [evaluation instructions](../../../../../docs/evaluation.md). + +## Prepare data + +Use the pre-extracted CrossDocked pocket10 release and its official +`split_by_name.pt`. With no local source, the builder downloads both files from +the upstream Google Drive release: + +```bash +uv sync --locked --extra crossdocked +uv run --no-sync python -m morph.dataset.conditional.pocket.build \ + --root data/crossdocked --workers 10 +``` + +Reuse the source files in the sibling morph checkout without installing the +download extra: + +```bash +python -m morph.dataset.conditional.pocket.build \ + --root data/crossdocked \ + --source-dir ../morph/data/crossdocked \ + --workers 10 +``` + +`--source-dir` accepts the parent of `crossdocked_pocket10/`, the extracted tree +itself, or the older layout with the tree beside `raw/split_by_name.pt`. +An archive can replace the extracted tree. `MORPH_CROSSDOCKED_RAW` provides the +same source override. Supplied sources are read-only; archive extraction goes +into the destination's `raw/` cache. With neither override, missing sources are +downloaded into that cache. + +Run preprocessing once on a CPU worker before distributed training. Workers +default to zero (serial); `--workers 10` enables a spawn-based process pool. +By default this command builds only the paired raw LMDBs. Fine-tuning reuses +the pretrained checkpoint's vocabulary and coordinate normalization. For training +from scratch, `--compute-statistics` also discovers a vocabulary and statistics +from CrossDocked training ligands. The existing `scripts/preprocess.py` still +handles the unconditional datasets. + +Outputs under `root/processed/`: + +- `train_data.lmdb`, `val_data.lmdb`, `test_data.lmdb`: paired raw records. +- `{split}_smiles.txt`, `{split}_ligand_fns.txt`: ordered identifiers, retaining + duplicates for novelty and ligand-frequency analysis. +- `crossdocked_summary.json`: processing settings, upstream revision, source + split checksum, held-out pocket directories, counts, inventories, and rejected + pairs with reasons and their original split indices. +- `atom_tokens.txt`, `edge_tokens.txt`, `charge_tokens.txt`, `distributions.pt`: + only with `--compute-statistics`; artifacts learned from training ligands. + +Completed caches can be reused without source files. All splits are published +together after a successful build; failed builds leave no partial processed +cache. Concurrent builders share an OS-managed lock. Changing hydrogen mode, +element/size filters, validation fraction, or seed requires a separate root. + +## Processing conventions + +The default is the updated DiffDec ligand path: read/sanitize the SDF, remove +**all** hydrogens (including isotopic H), then encode Kekulé bonds and formal +charges. Heavy-atom order and coordinates are preserved. GEOM's additional +radical/fragment validity screen is not applied, and there is no default ligand +size cutoff. `--max-atoms` enables an explicit cutoff. `--hydrogen-mode explicit` +materializes hydrogens with RDKit; use another root for that mode. + +There is no default element whitelist and no dependency on GEOM's vocabulary. +Raw records retain chemical identities independently of any model's token IDs. +Python callers can opt into a ligand-only filter with +`allowed_ligand_atom_symbols=checkpoint_vocab.atom_tokens`. This filter never +restricts protein-pocket elements. Rejections are recorded in the manifest. +Caches from the earlier implementation with its implicit GEOM filter must be +rebuilt under a new root; they are rejected by the cache version check. + +Pocket heavy atoms come from PDB `ATOM` and `HETATM` records and the element +column. Residue names, chain/residue/insertion-code grouping, and backbone flags +are retained. Hydrogens are removed; missing element labels are skipped as in +upstream. The stored pocket is the full supplied pocket10 structure. + +Both graphs are translated by the **same ligand centroid**, stored as +`lig_center` in Angstroms. Raw fields remain unscaled; tokenized reads divide +both sides by the supplied `distributions.coordinate_std` (the pretrained scale +when fine-tuning). Reading a pocket never +re-centers it. The encoder-facing pocket defaults to the 512 atoms nearest the +ligand centroid, preserving source order; `max_pocket_atoms=None` disables this. +Residue features remain available in the uncapped raw read. + +Validation is carved from official training pairs by whole pocket directory, +using upstream's deterministic algorithm (fraction 0.02, seed 42). Official +test order is retained among accepted records. `source_index` identifies the +original position in that split even when earlier records were rejected. + +## Reuse a ChEMBL3D no-H checkpoint + +Prepare and validate every retained pair before fine-tuning: + +```bash +python scripts/preprocess_pocket.py \ + --root data/crossdocked --source-dir ../morph/data/crossdocked \ + --pretrained-assets data/chembl3d_max100_implicit_h/processed --workers 15 + +# Euler: run from the repository root. Defaults use the same local inputs. +mkdir -p slurm_outputs +sbatch submission_scripts/euler/preprocess_pocket.sbatch +``` + +The script builds all three raw splits, applies the training reader's ligand +element filter, and validates tokenized ligands and protein context. It reuses +the checkpoint's statistics and discovers protein tokens from training pockets. +`processed/finetune_preprocessing_summary.json` records artifact checksums, +retained/dropped counts, unsupported elements, and validation status. Unsupported +bond/charge labels or protein tokens fail validation with the source ligand path. +The raw LMDB remains reusable; training applies the same vocabulary filter. +`MORPH_PRETRAINED_ASSETS` and `MORPH_CROSSDOCKED_RAW` override the submission +script's inputs; command-line flags can override its other settings. The CPU job +requests 16 cores and 32 GB for four hours, with output in +`slurm_outputs/pocket-preprocess-.log`. + +Keep `hydrogen_mode="implicit"` and load the **exact artifacts used by the +checkpoint**. Do not rediscover CrossDocked token IDs or substitute its coordinate +standard deviation: even equal vocabulary sizes can encode different classes. +Ligand atom, bond, and charge token ordering all come from these artifacts. +Supplying `vocab` automatically checks the split's ligand element inventory. +Molecules containing any unsupported atom type are excluded from the reader's +view, including their paired pockets. `dataset.vocab_filter_report` and the setup +log report missing elements and retained/dropped counts. All accessors and +sidecars follow the same filtered indices; metadata retains the original source +index. The raw LMDB is unchanged and can be reused with another checkpoint. +Raw readers without a vocabulary retain all processed pairs. For inspection, +`filter_by_vocab=False` disables this view filter; the training datamodule always +enables it. + +```python +import json +from pathlib import Path + +from morph.dataset.conditional.pocket import CrossDockedPocketDataset +from morph.dataset.core.preprocessing import Preprocessing +from morph.dataset.core.vocab import Vocab + +root = "data/crossdocked" +# Local checkpoint bundle, or the preprocessing directory that trained it. +pre = Preprocessing(root="/path/to/chembl3d-no-h-checkpoint-assets") + +# The new protein encoder can have its own vocabulary, discovered from training +# pockets. This does not extend or reorder the pretrained ligand vocabulary. +summary = json.loads(Path(root, "processed", "crossdocked_summary.json").read_text()) +pocket_vocab = Vocab( + atom_tokens=sorted(summary["splits"]["train"]["pocket_elements"]), + edge_tokens=[""], + charge_tokens=["0"], # Placeholder labels; protein charges are not inferred. +) +dataset = CrossDockedPocketDataset( + root, + split="test", + hydrogen_mode="implicit", + vocab=pre.vocab, + distributions=pre.distributions, + pocket_vocab=pocket_vocab, +) +ligand = dataset[0] # ChemAtomicData; normalized coordinates +pocket = dataset.get_pocket(0) # Same frame, empty bonds, neutral charge labels +raw_ligand = dataset.get(0) # Raw ligand tensors for statistics/scaffolds +raw_pocket = dataset.get_raw_pocket(0) # Full pocket and residue metadata +metadata = dataset.get_metadata(0) + +# Recover source coordinates from a tokenized ligand: +original_positions = ligand.positions * pre.distributions.coordinate_std +original_positions += metadata["lig_center"] +dataset.close() +``` + +Raw readers need no vocabulary. Tokenized readers use the supplied ligand +vocabulary, including its actual bond/charge token ordering. If a model is +already loaded with `load_pretrained`, pass its `.vocab` and `.distributions` +directly. For training from scratch, use the CrossDocked artifacts produced by +`--compute-statistics` instead. + +`get_pocket()` requires an explicit `pocket_vocab`; pass the ligand vocabulary +there too only if the protein encoder deliberately shares those categories. +Alternatively, use `get_raw_pocket()` for a residue-based or other protein +encoder. Metals in a pocket do not require new ligand output classes. Unsupported +ligand bond/charge labels or pocket elements raise errors during tokenization; +vocabularies are never silently expanded or remapped. Element filtering uses only +ligand atoms and applies consistently to train, validation, and test readers. + +All returned tensors/metadata can be modified without corrupting the cached +record. Readers support repeated dataset instances and fork/spawn DataLoader +workers. + +## Fine-tune with unconditional replay + +The `crossdocked/finetune` preset loads the pretrained ligand vocabulary and +coordinate scale, filters the pocket dataset, adds a protein encoder and gated +cross-attention to the DiT, and trains on a configurable source mixture: + +```bash +export MORPH_PRETRAINED_CKPT=/path/to/pretrained.ckpt +export MORPH_PRETRAINED_ASSETS=/path/to/pretrained/preprocessing/artifacts +python run.py defaults=crossdocked/finetune \ + data.pretrain_root=data/chembl3d_max100_implicit_h \ + data.pretrain_max_atoms=100 \ + data.finetune_fraction=0.3 \ + data.train_epoch_size=100000 +``` + +The example draws 30% pocket fine-tuning samples and 70% unconditional ChEMBL3D +samples **in expectation**, independently per example, with replacement within +each source. Set `data.finetune_fraction=p/100`; 0 and 1 select pure replay and +pure fine-tuning. The percentage is independent of corpus sizes and is not a +fixed quota in every batch. All augmentations of a draw share its source. +`train_epoch_size` counts source draws globally before augmentation; by default +it is the retained fine-tuning size (or pretraining size at fraction 0). DDP +rounds this count up to divide among ranks; incomplete training batches are +dropped. Seeds and epoch numbers define reproducible source/index draws. Keep +`trainer.trainer.use_distributed_sampler=false`: the replay sampler already +partitions draws across ranks. Evaluation is sharded separately. + +Replay uses only the **training split** of the pretraining corpus. Point +`pretrain_root`, `pretrain_max_atoms`, `pretrain_allowed_atom_symbols`, and +`hydrogen_mode` at the exact corpus variant used by the checkpoint. The preset +defaults to the unfiltered ChEMBL3D implicit-H variant; the example selects the +100-atom variant. Set `data.datamodule.pretrain_dataset._target_` and its options +to use another molecular source. Backbone/head architecture settings must match +the pretrained run; checkpoint loading retains the usual shape checks. + +Pretraining examples carry empty protein-context rows and remain unconditional +even after the cross-attention gates train. Pocket examples carry the protein +through the ligand's interpolation translation, rotation augmentation, batching, +and sampling. Validation and test use only the filtered pocket task, with no +replay. The discovered TRAIN protein vocabulary is saved in the run config. +The pretrained backbone and output heads train by default so replay can constrain +their updates; freezing remains configurable in the fine-tuning module. + +## Scaffold decoration inside a pocket + +`crossdocked/scaffold_decoration` ports the combined task from the same upstream +revision. A retained ligand scaffold provides node and bond conditioning; +protein atoms provide cross-attention context. Ligand, scaffold, and pocket +share translations and rotation augmentations. Evaluation can enumerate every +distinct ligand/scaffold pair, with the matching pocket attached to each pair. + +The default cutter is upstream's **LibINVENT** reaction enumerator, including +the reaction SMIRKS and DiffDec filter conditions from the morph checkout. +Install its optional dependency and prepare the additional caches on a CPU node: + +```bash +uv sync --locked --extra scaffold --extra crossdocked +sbatch submission_scripts/euler/preprocess_pocket.sbatch --scaffold-kind libinvent +``` + +The script reuses the pocket LMDBs and writes a separate +`processed/scaffold_libinvent_finetune_preprocessing_summary.json`. Scaffold +caches live under `data/crossdocked/scaffold_groups/`, keyed by the raw-data +manifest and allowed ligand elements. This prevents equal-length filtered +datasets from accidentally sharing cuts for different molecules. Molecules +with no accepted cut are excluded from the scaffold task. Reaction enumeration +is memoized across repeated ligand SMILES; scaffold-cache construction runs +serially after the parallel raw-data build. + +```bash +export MORPH_PRETRAINED_CKPT=/path/to/chembl3d-no-h.ckpt +export MORPH_PRETRAINED_ASSETS=/path/to/its/preprocessing/artifacts +python run.py defaults=crossdocked/scaffold_decoration \ + data.pretrain_root=data/chembl3d_max100_implicit_h \ + data.pretrain_max_atoms=100 \ + data.finetune_fraction=0.3 +``` + +This uses the same checkpoint vocabulary, normalization, and source-probability +replay as the de-novo pocket task. Unconditional replay carries empty pockets +and zero keep masks, including in batches containing only replay examples. +The scaffold's identities, internal bonds, and formal charges remain fixed. +Scaffold coordinates are movable and supervised, with 0.1 Å training jitter and +no inference jitter. The pretrained body is frozen; rank-8 LoRA, conditioning +modules, and complete prediction heads (including the coordinate readout) train. +Peak rates are Muon `1e-3` and auxiliary Adam `1e-4`. +The earlier rigid-scaffold experiment can be reproduced by setting +`model.module.freeze_keep_positions=true`, +`model.module.exclude_scaffold_from_x_loss=true`, and `data.jitter_scale=0.0`. +Choose `data.scaffold_kind=murcko`, `rgroup`, or `linker` for other cuts, and +preprocess with the matching `--scaffold-kind`. +Prediction through the task datamodule and `module.sample` uses the shared +scaffold constraints and restores frozen poses to their input coordinate frame. + +### Scaffold evaluation + +The default evaluation panel selects up to 512 fixed scaffold/pocket pairs with +seed 42 and draws eight generations per pair. Selection is over enumerated cuts; +identical scaffold topologies in different pocket contexts remain separate +conditions. Smaller splits use every available pair. The two-GPU preset evaluates +the complete panel rather than truncating its repeats. Training and replay +sampling are unchanged. + +Stable pair and attempt IDs activate `val/scaffold/*`: retention, valid retention, +unaligned/aligned RMSD, growth, recovery, uniqueness, unique yield, and diversity. +These are averaged over evaluated pairs; they are not DiffDec's per-pocket +benchmark definitions. `val/scaffold/posebusters/*` additionally reports the +existing inherited-clash adjustment alongside the unchanged strict +`val/posebusters/*` scores. The adjustment exempts only verified, non-worsened +clashes between retained atoms; bond-length and bond-angle failures still count. + +The original job 16758459 used an enumeration wrapper without scoring IDs, so +it logged no scaffold metrics. Its frozen code and historical results are +unchanged; measuring these metrics on that checkpoint requires fresh evaluation. +Routine PoseBusters here checks ligand geometry only, without protein clashes, +energy/strain, or flatness. Full-receptor docking evaluation remains separate. + +## Upstream protein-preparation fixes + +The student changes reviewed include `bab289e` (public receptor-frame metadata), +`144204b` (ordered SMILES and ligand-path access), and the docking fixes +`0f7c69c` / `26410d1` (protocol-specific receptor preparation and cache names). +Remote `376446f` also associates PoseBusters checks with each molecule's receptor. + +The data changes are included here. Docking preparation belongs to the future +evaluation port: training uses cropped pocket10 structures, whereas benchmark +docking needs the corresponding **full receptors** from `test_set.zip`, prepared +with the selected protocol. This builder neither hydrogenates receptor PDBs nor +produces receptor PDBQT files. Metadata preserves the source paths and coordinate +translation needed to apply those evaluation fixes later. + +Evaluation was also checked against upstream `main` at +`f81996ace95ee45c2e1d23332252d38137b3a918` (2026-08-30): + +- The de-novo pocket recipe has an **opt-in** `PocketEvalCallback`, configured + for 20 validation pockets and 20 ligands each. It filters out validation + protein-family names seen in training, measures within-pocket diversity, + novelty, completeness, drug-likeness, size, and fast PoseBusters, and supplies + a guarded diversity-plus-novelty selection score. Test metrics are diagnostic. + This callback is not enabled by default or inherited by the scaffold recipe. +- Offline `morph.eval.pocket` restores the crystal frame, uses full receptors + and named docking protocols, scores Vina Score/Min (optional Dock), and + writes per-molecule and per-pocket results. `--pose-quality` adds hydrogens + for full PoseBusters, receptor-distance/overlap checks, and strain. Protocol + receptor preparation and correct ligand translation are necessary for these + scores; the cropped training pocket is not an equivalent receptor. +- `morph.eval.scaffold` reports scaffold-to-generated similarity and identity + (no-decoration collapse), plus conformer quality of valid outputs. Its + denominators differ from this repository's all-attempt scaffold metrics. +- The sibling checkout additionally contains local `eval_scripts/` for the + DiffDec benchmark, absent from tracked upstream `main`. They map 102 prescribed + test instances and use repeated generations (normally 100 per instance). + Their validity is scaffold containment plus growth, with permissive parsing, + and recovery ignores stereochemistry. Metrics aggregate by pocket; uniqueness + includes both the publication's fixed denominator and a corrected variant. + Our rebuilt LibINVENT test cache has 82 cuts across 31 ligands, so it cannot + be presented as that exact benchmark without importing the prescribed cuts. diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2f9665e597c1d0f55cab86d1a4d57cd2b13b3c2c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/__init__.py @@ -0,0 +1,5 @@ +"""CrossDocked ligand targets and fixed protein-pocket context.""" + +from morph.dataset.conditional.pocket.crossdocked import CrossDockedPocketDataset + +__all__ = ["CrossDockedPocketDataset"] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/build.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/build.py new file mode 100644 index 0000000000000000000000000000000000000000..37d74d950cb9c8bd4194ea0e195539952f768c52 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/build.py @@ -0,0 +1,366 @@ +"""Build CrossDocked train/val/test LMDBs, optionally discovering ligand tokens. + +Run ``python -m morph.dataset.conditional.pocket.build --help`` for the CLI. +""" + +from __future__ import annotations + +import argparse +import collections +import fcntl +import hashlib +import json +import os +import pickle +import random +import tempfile +from contextlib import nullcontext +from functools import partial +from multiprocessing import get_context +from pathlib import Path + +import torch +from rdkit import Chem + +from morph.dataset.conditional.pocket.processing import ( + ensure_crossdocked_raw, + parse_pocket, + process_ligand_diffdec, +) +from morph.dataset.core.lmdb import RECORD_FIELDS, SPLITS, write_bytes_to_lmdb + +UPSTREAM_REVISION = "f81996ace95ee45c2e1d23332252d38137b3a918" +MANIFEST = "crossdocked_summary.json" + + +def processing_options( + hydrogen_mode, max_atoms, allowed_ligand_atom_symbols, val_fraction, seed +): + """Validate and normalize all settings that affect cache contents.""" + if hydrogen_mode not in ("implicit", "explicit"): + raise ValueError("hydrogen_mode must be implicit or explicit") + if max_atoms is not None and max_atoms < 1: + raise ValueError("max_atoms must be positive or None") + if not 0 <= val_fraction < 1: + raise ValueError("val_fraction must be in [0, 1)") + return { + "schema_version": 2, + "processing": "diffdec", + "hydrogen_mode": hydrogen_mode, + "max_atoms": max_atoms, + "allowed_ligand_atom_symbols": ( + sorted(set(allowed_ligand_atom_symbols)) + if allowed_ligand_atom_symbols is not None + else None + ), + "val_fraction": val_fraction, + "seed": seed, + } + + +def read_manifest(root: str | Path, options: dict) -> dict | None: + """Reuse only complete caches built with the requested processing settings.""" + processed = Path(root) / "processed" + path = processed / MANIFEST + if not path.is_file(): + if processed.is_dir() and any(processed.iterdir()): + raise ValueError( + f"Incomplete or incompatible CrossDocked cache: {processed}" + ) + return None + manifest = json.loads(path.read_text()) + if manifest["options"] != options: + raise ValueError( + f"Cache uses different CrossDocked preprocessing options: {root}; " + "choose a separate root for another hydrogen mode, filter, or split" + ) + if not all( + (processed / f"{split}_data.lmdb" / "data.mdb").is_file() for split in SPLITS + ): + raise ValueError(f"Incomplete CrossDocked cache: {processed}") + return manifest + + +def carve_val(train_entries, val_fraction: float, seed: int = 42): + """Hold out whole pocket directories, matching morph's grouped validation split.""" + if not 0 <= val_fraction < 1: + raise ValueError("val_fraction must be in [0, 1)") + by_directory = collections.defaultdict(list) + for entry in train_entries: + by_directory[entry[0].split("/")[0]].append(entry) + directories = sorted(by_directory) + random.Random(seed).shuffle(directories) + target, count, val_directories = int(len(train_entries) * val_fraction), 0, set() + for directory in directories: + if count >= target: + break + val_directories.add(directory) + count += len(by_directory[directory]) + train = [ + e + for d in sorted(by_directory) + if d not in val_directories + for e in by_directory[d] + ] + val = [e for d in sorted(val_directories) for e in by_directory[d]] + return train, val, val_directories + + +def _load_splits(path: Path, val_fraction: float, seed: int): + # The official release contains Python tuples, not just tensors. + split = torch.load(path, map_location="cpu", weights_only=False) + for name in ("train", "test"): + for entry in split[name]: + if len(entry) != 2 or any( + not isinstance(p, str) or Path(p).is_absolute() or ".." in Path(p).parts + for p in entry + ): + raise ValueError(f"Invalid CrossDocked pair in {name}: {entry!r}") + train_dirs = {e[0].split("/")[0] for e in split["train"]} + test_dirs = {e[0].split("/")[0] for e in split["test"]} + if train_dirs & test_dirs: + raise ValueError("CrossDocked train/test pocket directories overlap") + train, val, val_dirs = carve_val(split["train"], val_fraction, seed) + if not train: + raise ValueError("The grouped validation split leaves no training complexes") + return {"train": train, "val": val, "test": split["test"]}, sorted(val_dirs) + + +def _process_one(indexed_entry, *, tree: Path, options: dict): + source_index, (pocket_fn, ligand_fn) = indexed_entry + data = process_ligand_diffdec(tree / ligand_fn, options["hydrogen_mode"]) + if data is None: + return None, "lig_unreadable" + if options["max_atoms"] is not None and data.num_nodes > options["max_atoms"]: + return None, "lig_too_large" + allowed = options["allowed_ligand_atom_symbols"] + if allowed is not None: + table = Chem.GetPeriodicTable() + if any(table.GetElementSymbol(z) not in allowed for z in data.z.tolist()): + return None, "lig_offvocab" + try: + pocket = parse_pocket(tree / pocket_fn) + except (OSError, ValueError, IndexError): + return None, "pocket_unreadable" + if pocket is None: + return None, "pocket_empty" + # One translation for both sides. Store it before normalization so future + # sampling/evaluation can recover the original, full-receptor frame. + center = data.pos.mean(dim=0) + record = {key: getattr(data, key) for key in RECORD_FIELDS} + record.update( + pos=data.pos - center, + poc_pos=pocket["pos"] - center.numpy(), + poc_elem=pocket["elem"], + poc_resname=pocket["resname"], + poc_backbone=pocket["backbone"], + poc_resid=pocket["resid"], + lig_center=center, + pocket_fn=pocket_fn, + ligand_fn=ligand_fn, + source_index=source_index, + ) + # Serialize in the worker, avoiding one shared-memory allocation/descriptor + # per tensor when a full release is streamed through a multiprocessing pool. + return pickle.dumps(record, protocol=4), "ok" + + +def _worker_init(): + torch.set_num_threads(1) + + +def _build_split(name, entries, directory, tree, options, workers, chunk_size): + status = collections.Counter() + rejected = [] + smiles, ligand_fns = [], [] + residues, ligand_elements, pocket_elements = ( + set(), + collections.Counter(), + collections.Counter(), + ) + convert = partial(_process_one, tree=tree, options=options) + pool_context = ( + get_context("spawn").Pool(workers, initializer=_worker_init) + if workers > 1 + else nullcontext() + ) + with pool_context as pool: + results = ( + pool.imap(convert, enumerate(entries), chunksize=chunk_size) + if pool is not None + else map(convert, enumerate(entries)) + ) + + def stream(): + for index, (blob, reason) in enumerate(results): + status[reason] += 1 + if blob is None: + rejected.append( + { + "source_index": index, + "pair": entries[index], + "reason": reason, + } + ) + yield None + continue + record = pickle.loads(blob) + smiles.append(record["smiles"]) + ligand_fns.append(record["ligand_fn"]) + residues.update(record["poc_resname"]) + ligand_elements.update(str(z) for z in record["z"].tolist()) + pocket_elements.update(record["poc_elem"]) + yield blob + + written, _ = write_bytes_to_lmdb( + stream(), str(directory / f"{name}_data.lmdb"), total=len(entries) + ) + if entries and not written: + raise ValueError(f"No valid complexes in {name}: {dict(status)}") + for suffix, values in (("smiles", smiles), ("ligand_fns", ligand_fns)): + (directory / f"{name}_{suffix}.txt").write_text( + "".join(f"{value}\n" for value in values) + ) + return { + "count": written, + "source_count": len(entries), + "status": dict(status), + "rejected": rejected, + "residues": sorted(residues), + "ligand_atomic_numbers": dict(ligand_elements), + "pocket_elements": dict(pocket_elements), + } + + +def build_lmdbs( + root: str | Path, + source_dir: str | Path | None = None, + *, + hydrogen_mode: str = "implicit", + max_atoms: int | None = None, + allowed_ligand_atom_symbols=None, + val_fraction: float = 0.02, + seed: int = 42, + preprocess_workers: int = 0, + preprocess_chunk_size: int = 32, +) -> dict: + """Build missing data once, publishing all splits together after success. + + The OS releases the build lock even if a process dies. Readers never see + partially written LMDBs. Completed caches work without the raw sources. + Raw chemistry is independent of model vocabularies. An explicit ligand + element filter may be supplied; it never filters protein-pocket elements. + """ + options = processing_options( + hydrogen_mode, max_atoms, allowed_ligand_atom_symbols, val_fraction, seed + ) + if preprocess_workers < 0 or preprocess_chunk_size < 1: + raise ValueError("Workers must be non-negative and chunk size positive") + root = Path(root) + cached = read_manifest(root, options) + if cached is not None: + return cached + root.mkdir(parents=True, exist_ok=True) + with (root / ".crossdocked.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_manifest(root, options) + if cached is not None: + return cached + tree, split_path = ensure_crossdocked_raw(root / "raw", source_dir) + splits, val_dirs = _load_splits(split_path, val_fraction, seed) + with tempfile.TemporaryDirectory(dir=root) as staging: + output = Path(staging) / "processed" + output.mkdir() + split_results = { + name: _build_split( + name, + entries, + output, + tree, + options, + preprocess_workers, + preprocess_chunk_size, + ) + for name, entries in splits.items() + } + manifest = { + "options": options, + "upstream_revision": UPSTREAM_REVISION, + "split_sha256": hashlib.sha256(split_path.read_bytes()).hexdigest(), + "val_directories": val_dirs, + "splits": split_results, + } + (output / MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n") + os.replace(output, root / "processed") + return manifest + + +def compute_vocab_distributions(root: str | Path, **dataset_options): + """Discover tokens/statistics for training from scratch in processed/. + + Fine-tuning must instead reuse the pretrained model's vocabulary and + coordinate normalization; discovering new tokens can change their indices. + """ + from morph.dataset.conditional.pocket.crossdocked import ( # noqa: PLC0415 + CrossDockedPocketDataset, + ) + from morph.dataset.core.preprocessing import Preprocessing # noqa: PLC0415 + + root = Path(root) + dataset = CrossDockedPocketDataset(root, split="train", **dataset_options) + try: + return Preprocessing( + root=str(root / "processed"), + train_dataset=dataset, + num_workers=dataset_options.get("preprocess_workers", 0), + ) + finally: + dataset.close() + + +def main(argv: list[str] | None = None) -> None: + """Prepare pocket data without constructing a model or training datamodule.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path("data/crossdocked")) + parser.add_argument("--source-dir", type=Path) + parser.add_argument("--workers", type=int, default=0) + parser.add_argument("--val-frac", type=float, default=0.02) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--hydrogen-mode", choices=("implicit", "explicit"), default="implicit" + ) + parser.add_argument("--max-atoms", type=int) + parser.add_argument( + "--compute-statistics", + action="store_true", + help="discover ligand vocabulary/statistics for training from scratch; " + "for fine-tuning, reuse the checkpoint's preprocessing artifacts", + ) + args = parser.parse_args(argv) + if ( + args.workers < 0 + or not 0 <= args.val_frac < 1 + or (args.max_atoms is not None and args.max_atoms < 1) + ): + parser.error( + "workers must be non-negative, val-frac in [0, 1), and max-atoms positive" + ) + if int(os.environ.get("WORLD_SIZE", "1")) > 1: + parser.error("run preprocessing as one process, before torchrun") + options = dict( + source_dir=args.source_dir, + preprocess_workers=args.workers, + val_fraction=args.val_frac, + seed=args.seed, + hydrogen_mode=args.hydrogen_mode, + max_atoms=args.max_atoms, + ) + manifest = build_lmdbs(args.root, **options) + if args.compute_statistics: + compute_vocab_distributions(args.root, **options) + counts = {name: value["count"] for name, value in manifest["splits"].items()} + print(f"CrossDocked ready at {args.root / 'processed'}: {counts}") + + +if __name__ == "__main__": + main() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/crossdocked.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/crossdocked.py new file mode 100644 index 0000000000000000000000000000000000000000..a63dcd00c7917f706cdb9cf299448e6f2ea8a5cd --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/crossdocked.py @@ -0,0 +1,320 @@ +"""Lazy paired ligand/pocket reads in a shared, ligand-centered coordinate frame.""" + +from __future__ import annotations + +import logging +import os +import pickle +import weakref +from pathlib import Path + +import torch +from rdkit import Chem +from torch.utils.data import Dataset +from torch_geometric.data import Data + +from morph.dataset.core.atomic_data import make_chem_atomic +from morph.dataset.core.lmdb import RECORD_FIELDS, SPLITS, _int_key, open_read_env +from morph.dataset.core.representation import Capabilities +from morph.dataset.core.tokenized_decode import TokenizedDecodeMixin, _lookup_tokens + +LOGGER = logging.getLogger(__name__) + + +class _Reader: + """Share immutable LMDB mappings between datasets in the same process.""" + + def __init__(self, path): + self.pid = os.getpid() + self.env = open_read_env(str(path)) + self.txn = self.env.begin(buffers=True) + + def __del__(self): + self.close() + + def close(self): + """Release the mapping, including handles inherited by forked workers.""" + if getattr(self, "txn", None) is not None: + self.txn.abort() + self.txn = None + if getattr(self, "env", None) is not None: + self.env.close() + self.env = None + + +_READERS: weakref.WeakValueDictionary = weakref.WeakValueDictionary() + + +class CrossDockedPocketDataset(TokenizedDecodeMixin, Dataset): + """CrossDocked ligands with independently accessible protein-pocket context. + + ``get(i)`` returns raw ligand fields for existing preprocessing/scaffold + utilities. ``dataset[i]`` uses the supplied ligand vocabulary and + distributions. ``get_pocket(i)`` requires an explicit ``pocket_vocab`` and + uses the same coordinate scale. Both return ChemAtomicData. + ``get_raw_pocket(i)`` retains the complete, untrimmed pocket and residue + information; ``get_metadata(i)`` provides the original receptor translation. + + Missing caches are built automatically. The default processing matches + morph's DiffDec heavy-atom recipe; no size or element filter is imposed. + For fine-tuning, supply the checkpoint's vocabulary and distributions. + Tokenized readers automatically exclude ligands with unsupported elements; + ``vocab_filter_report`` records coverage and dropped counts. Raw caches stay + unchanged. ``filter_by_vocab=False`` opts out for inspection only. + """ + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root: str | Path, + vocab=None, + distributions=None, + split: str = "train", + source_dir: str | Path | None = None, + hydrogen_mode: str = "implicit", + max_atoms: int | None = None, + allowed_ligand_atom_symbols=None, + val_fraction: float = 0.02, + seed: int = 42, + max_pocket_atoms: int | None = 512, + preprocess_workers: int = 0, + preprocess_chunk_size: int = 32, + *, + pocket_vocab=None, + filter_by_vocab: bool = True, + ): + # Keep the build CLI importable via python -m without pre-importing it. + from morph.dataset.conditional.pocket.build import build_lmdbs # noqa: PLC0415 + + if split not in SPLITS: + raise ValueError(f"split must be one of {SPLITS}") + if max_pocket_atoms is not None and max_pocket_atoms < 1: + raise ValueError("max_pocket_atoms must be positive or None") + self.root, self.split = str(root), split + self.processed_dir = str(Path(root) / "processed") + self.lmdb_path = str(Path(self.processed_dir) / f"{split}_data.lmdb") + self.vocab, self.distributions = vocab, distributions + self.pocket_vocab = pocket_vocab + self.max_pocket_atoms = max_pocket_atoms + manifest = build_lmdbs( + root, + source_dir, + hydrogen_mode=hydrogen_mode, + max_atoms=max_atoms, + allowed_ligand_atom_symbols=allowed_ligand_atom_symbols, + val_fraction=val_fraction, + seed=seed, + preprocess_workers=preprocess_workers, + preprocess_chunk_size=preprocess_chunk_size, + ) + self._length = manifest["splits"][split]["count"] + self._reader = None + self._cache_index = self._cache_record = None + self._indices = None + self.vocab_filter_report = None + if vocab is not None: + self._build_token_maps(vocab) + if filter_by_vocab: + self._filter_ligands(manifest["splits"][split]) + + def _filter_ligands(self, split_summary): + """Check raw element coverage and retain only checkpoint-compatible ligands.""" + table = Chem.GetPeriodicTable() + present = { + table.GetElementSymbol(int(z)) + for z in split_summary["ligand_atomic_numbers"] + } + missing = sorted(present - set(self.vocab.atom_tokens)) + source_count = self._length + # The inventory makes the common, fully compatible case constant-time. + if missing: + allowed = self._z_to_atom_idx >= 0 + retained = [ + i + for i in range(source_count) + if bool(allowed[self._read(i)["z"]].all()) + ] + self.close() + self._indices = torch.tensor(retained, dtype=torch.long) + self._length = len(retained) + self.vocab_filter_report = { + "source_count": source_count, + "retained": self._length, + "dropped": source_count - self._length, + "missing_elements": missing, + } + LOGGER.info( + "CrossDocked %s checkpoint vocabulary: %s", + self.split, + self.vocab_filter_report, + ) + + def __len__(self): + """Count retained ligand-pocket pairs in this split.""" + return self._length + + def _read(self, index): + if index < 0 or index >= self._length: + raise IndexError(index) + if self._indices is not None: + index = int(self._indices[index]) + if self._reader is None or self._reader.pid != os.getpid(): + # Drop inherited handles before opening a fresh mapping after fork. + for key, reader in list(_READERS.items()): + if key[0] != os.getpid(): + reader.close() + del _READERS[key] + self.close() + key = (os.getpid(), str(Path(self.lmdb_path).resolve())) + reader = _READERS.get(key) + if reader is None: + reader = _Reader(key[1]) + _READERS[key] = reader + self._reader = reader + if self._cache_index != index: + raw = self._reader.txn.get(_int_key(int(index))) + if raw is None: + raise ValueError( + f"Missing CrossDocked record {index}: {self.lmdb_path}" + ) + self._cache_record = pickle.loads(raw) + self._cache_index = index + return self._cache_record + + def close(self) -> None: + """Release this dataset's reference to the process-local mapping.""" + self._reader = None + self._cache_index = self._cache_record = None + + def __getstate__(self): + """Exclude handles and cached records when spawning DataLoader workers.""" + return { + **self.__dict__, + "_reader": None, + "_cache_index": None, + "_cache_record": None, + } + + def get(self, index) -> Data: + """Read unnormalized ligand tensors, preserving source atom order.""" + record = self._read(index) + return Data(**{key: record[key] for key in RECORD_FIELDS}).clone() + + def get_raw_pocket(self, index) -> dict: + """Return untrimmed pocket fields in ligand-centered Angstroms, as copies.""" + record = self._read(index) + return { + key: record[f"poc_{key}"].copy() + for key in ("pos", "elem", "resname", "backbone", "resid") + } + + def get_metadata(self, index) -> dict: + """Return provenance and translation back into the original receptor frame.""" + record = self._read(index) + return { + "lig_center": torch.as_tensor(record["lig_center"]).clone().float(), + "pocket_fn": record["pocket_fn"], + "ligand_fn": record["ligand_fn"], + "smiles": record["smiles"], + "source_index": record["source_index"], + } + + def _require_tokens(self): + if self.vocab is None or self.distributions is None: + raise RuntimeError( + "A vocab and distributions are required; use .get() or " + ".get_raw_pocket() for raw data" + ) + + def _scale(self, positions): + std = self.distributions.coordinate_std + return positions / std if std is not None else positions.clone() + + def __getitem__(self, index): + """Tokenize the ligand without changing its frame relative to the pocket.""" + self._require_tokens() + data = self.get(index) + return make_chem_atomic( + x=self._scale(data.pos), + a=_lookup_tokens(data.z, self._z_to_atom_idx, "atom"), + c=_lookup_tokens( + data.charges - self._charge_min, self._charge_to_idx, "charge" + ), + e=_lookup_tokens(data.edge_attr, self._bond_to_idx, "bond"), + edge_index=data.edge_index, + smiles=data.smiles, + ) + + def get_pocket(self, index): + """Tokenize heavy pocket atoms, optionally retaining the nearest inner shell. + + Pocket charges are neutral placeholders and its edges are empty, as in + morph. Cropping never re-centers the pocket. Unknown elements fail + explicitly. The protein vocabulary is independent of the ligand output + vocabulary; raw pockets remain available for other protein encoders. + """ + if self.pocket_vocab is None or self.distributions is None: + raise RuntimeError( + "A pocket_vocab and distributions are required; use " + ".get_raw_pocket() for raw data" + ) + raw = self.get_raw_pocket(index) + pos = torch.from_numpy(raw["pos"]).float() + keep = torch.arange(len(pos)) + if self.max_pocket_atoms is not None and len(pos) > self.max_pocket_atoms: + keep = ( + torch.argsort(pos.square().sum(dim=1), stable=True)[ + : self.max_pocket_atoms + ] + .sort() + .values + ) + elem_to_idx = {e: i for i, e in enumerate(self.pocket_vocab.atom_tokens)} + elements = [raw["elem"][i] for i in keep.tolist()] + unknown = sorted(set(elements) - elem_to_idx.keys()) + if unknown: + raise ValueError( + f"Pocket elements absent from the pocket vocabulary: {unknown}" + ) + if "0" not in self.pocket_vocab.charge_tokens: + raise ValueError("Pocket context requires neutral charge token '0'") + pocket = make_chem_atomic( + x=self._scale(pos[keep]), + a=torch.tensor([elem_to_idx[e] for e in elements], dtype=torch.long), + c=torch.full( + (len(keep),), + self.pocket_vocab.charge_tokens.index("0"), + dtype=torch.long, + ), + e=torch.empty(0, dtype=torch.long), + edge_index=torch.empty((2, 0), dtype=torch.long), + ) + pocket.add_node_property("backbone", torch.from_numpy(raw["backbone"])[keep]) + pocket.add_node_property("residue_index", torch.from_numpy(raw["resid"])[keep]) + return pocket + + def _field_values(self, key, suffix): + path = Path(self.processed_dir) / f"{self.split}_{suffix}.txt" + if path.is_file(): + values = path.read_text().splitlines() + source_count = ( + self.vocab_filter_report["source_count"] + if self.vocab_filter_report is not None + else self._length + ) + if len(values) == source_count: + return ( + values + if self._indices is None + else [values[i] for i in self._indices.tolist()] + ) + return [str(self._read(i)[key]) for i in range(self._length)] + + def get_all_smiles(self) -> list[str]: + """Return ordered ligand SMILES, keeping duplicates for frequency analysis.""" + return self._field_values("smiles", "smiles") + + def get_all_ligand_fns(self) -> list[str]: + """Return ordered source ligand paths, matching retained dataset indices.""" + return self._field_values("ligand_fn", "ligand_fns") diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/datamodule.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..312128e4fb59adc04c30145fd0db67b7e91ed735 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/datamodule.py @@ -0,0 +1,118 @@ +"""Pocket conditioning and unconditional replay in a shared training batch.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import hydra +import torch +from omegaconf import OmegaConf + +from morph.dataset.conditional.replay import ReplayLightningDataModule +from morph.dataset.core.atomic_data import chembatch_from_graphs, make_chem_atomic +from morph.dataset.core.flow_matching_wrapper import ( + FlowMatchingDatasetWrapper, + eval_collate_fn, + train_collate_fn, +) +from morph.dataset.core.vocab import Vocab + + +class PocketFlowMatchingWrapper(FlowMatchingDatasetWrapper): + """Keep protein context in the interpolated ligand's translated/rotated frame.""" + + coframed_context_keys = ("pocket_data",) + + def _attach_context(self, index, mol_t): + pocket = self.base_dataset.get_pocket(index) + shift = getattr(mol_t, "frame_shift", None) + if shift is not None: + pocket.x = pocket.x - shift + mol_t.pocket_data = pocket + + +def _pocket_batch(samples): + pockets = [] + for sample in samples: + pocket = getattr(sample[0], "pocket_data", None) + if pocket is None: + # Empty rows preserve graph IDs for unconditional replay. The model + # masks these rows out of both branches of each context adapter. + pocket = make_chem_atomic( + x=torch.empty((0, 3)), + a=torch.empty(0, dtype=torch.long), + c=torch.empty(0, dtype=torch.long), + e=torch.empty(0, dtype=torch.long), + edge_index=torch.empty((2, 0), dtype=torch.long), + ) + pockets.append(pocket) + return chembatch_from_graphs(pockets) + + +def pocket_train_collate_fn(batch): + """Batch mixed examples and retain empty protein rows for pretraining items.""" + samples = [ + sample + for item in batch + for sample in (item if isinstance(item, list) else [item]) + ] + result = train_collate_fn(samples) + result[0].pocket = _pocket_batch(samples) + return result + + +def pocket_eval_collate_fn(batch): + """Attach fixed pockets to evaluation priors for the sampling integrator.""" + result = eval_collate_fn(batch) + result[0].pocket = _pocket_batch(batch) + return result + + +class PocketLightningDataModule(ReplayLightningDataModule): + """Filtered pocket targets plus a configurable pretraining replay fraction.""" + + wrapper_cls = PocketFlowMatchingWrapper + train_collate_fn = staticmethod(pocket_train_collate_fn) + eval_collate_fn = staticmethod(pocket_eval_collate_fn) + + def __init__(self, *args, pocket_vocab=None, **kwargs): + super().__init__(*args, **kwargs) + self.pocket_vocab = pocket_vocab + + def _wrap_dataset(self, dataset_cfg, stage): + dataset_cfg = OmegaConf.to_container(dataset_cfg, resolve=True) + if "allowed_atom_symbols" in dataset_cfg: + dataset_cfg["allowed_ligand_atom_symbols"] = dataset_cfg.pop( + "allowed_atom_symbols" + ) + base = hydra.utils.instantiate( + dataset_cfg, + vocab=self.vocab, + distributions=self.distributions, + split=stage, + filter_by_vocab=True, + ) + if self.pocket_vocab is None: + summary = json.loads( + Path(base.processed_dir, "crossdocked_summary.json").read_text() + ) + self.pocket_vocab = Vocab( + sorted(summary["splits"]["train"]["pocket_elements"]), + [""], + ["0"], + ) + base.pocket_vocab = self.pocket_vocab + return self._wrap_pocket(base, stage) + + def _wrap_pocket(self, base, stage): + """Attach task-specific wrappers after checkpoint filtering and token setup.""" + return self.wrapper_cls(base_dataset=base, **self._wrapper_kwargs(stage)) + + def configure_model(self, cfg): + """Save protein tokens in the run config for model construction and reuse.""" + cfg.data.pocket_vocab = OmegaConf.structured(self.pocket_vocab) + + def val_dataloader(self): + """Keep the final batch: the task validation split may be small.""" + return self._eval_dataloaders(self.val_datasets, "val", drop_last=False) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/evaluation.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/evaluation.py new file mode 100644 index 0000000000000000000000000000000000000000..78f775c36431883436a14b72215ef9c655228a2f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/evaluation.py @@ -0,0 +1,196 @@ +"""Fixed scaffold/pocket conditions for the evaluation protocol used in DiffDec.""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import PurePosixPath + +import torch +from rdkit import Chem +from torch.utils.data import Dataset + +from morph.dataset.conditional.scaffold.wrappers import evaluation_pair +from morph.utils.metrics.diffdec import DIFFDEC_SAMPLES_PER_CONDITION + + +def graph_smiles(graph, vocab): + """Serialize a reference graph; malformed references must stop evaluation.""" + mol = graph.to_rdkit_mol(vocab.atom_tokens, vocab.edge_tokens, vocab.charge_tokens) + if mol is None: + raise ValueError("Cannot decode evaluation reference") + return Chem.MolToSmiles(mol) + + +def cached_cut_conditions(wrapped): + """Enumerate Morph's cached cuts, explicitly a different benchmark panel.""" + if wrapped._slicing_offsets is None: + raise ValueError("Evaluation requires fixed, enumerated scaffold cuts") + conditions = [] + for base_idx in wrapped._filtered_indices: + base_idx = int(base_idx) + target = wrapped.base_dataset[base_idx] + metadata = wrapped.base_dataset.get_metadata(base_idx) + for indices in wrapped._scaffold_choices(base_idx): + mask = torch.zeros(target.num_nodes, dtype=torch.bool) + mask[list(indices)] = True + conditions.append( + { + "uuid": str(len(conditions)), + "base_idx": base_idx, + "atom_indices": list(indices), + "scaffold": graph_smiles(target.filter_nodes(mask), wrapped.vocab), + "true_molecule": graph_smiles(target, wrapped.vocab), + "protein_filename": metadata["pocket_fn"], + } + ) + return conditions + + +def _canonical(smiles): + mol = Chem.MolFromSmiles(smiles) + if mol is None: + raise ValueError(f"Malformed benchmark reference: {smiles!r}") + return Chem.MolToSmiles(Chem.RemoveHs(mol)) + + +def _protein_key(filename): + parts = PurePosixPath(filename).parts + if "crossdocked_pocket10" in parts: + parts = parts[parts.index("crossdocked_pocket10") + 1 :] + return "/".join(parts) + + +def published_cut_conditions(base, entries, table=None, *, tolerance=0.05): + """Map the published DiffDec cuts to this dataset by element and absolute pose. + + ``entries`` is a released ``crossdock{single,multi}_test_full.pt`` list; optional + ``table`` contains dictionaries from its matching ``*_test_table.csv``. Every UUID + must map uniquely, including duplicate cuts/ligands: never deduplicate or + silently drop benchmark cases. ``charges`` in DiffDec's file are atomic + numbers, with zero for fake atoms; no model vocabulary is assumed here. + + Coordinates are matched within 0.05 Angstrom by default, far below a bond + length. Native ligand bond labels and the checkpoint's pocket representation + are retained. With a table, reference strings/grouping keys are kept verbatim. + Without one, ligand SMILES come from ``entry['name']`` and scaffold SMILES + and protein identities from the mapped CrossDocked record. The public Zenodo + release supplies tensors but no CSV tables. + This imports the conditions, not DiffDec's XYZ/Open Babel bond perception. + """ + if tolerance <= 0: + raise ValueError("Coordinate matching tolerance must be positive") + by_uuid = {str(row["uuid"]): row for row in table} if table is not None else None + if by_uuid is not None and len(by_uuid) != len(table): + raise ValueError("Duplicate UUID in DiffDec reference table") + uuids = {str(entry["uuid"]) for entry in entries} + if by_uuid is not None and uuids != set(by_uuid): + raise ValueError("DiffDec tensor file and reference table have different UUIDs") + if len(entries) != len(uuids): + raise ValueError("Duplicate UUID in DiffDec tensor file") + by_smiles = defaultdict(list) + for i in range(len(base)): + metadata = base.get_metadata(i) + by_smiles[_canonical(metadata["smiles"])].append(i) + + conditions = [] + for entry in entries: + uuid = str(entry["uuid"]) + row = by_uuid[uuid] if by_uuid is not None else {"molecule": entry["name"]} + if _canonical(entry["name"]) != _canonical(row["molecule"]): + raise ValueError(f"DiffDec UUID {uuid}: tensor/table ligand mismatch") + z = torch.as_tensor(entry["charges"]).flatten().long() + positions = torch.as_tensor(entry["positions"]).double() + scaffold = torch.as_tensor(entry["scaffold_only_mask"]).flatten().bool() + ligand = scaffold | torch.as_tensor(entry["rgroup_mask"]).flatten().bool() + ligand &= z > 0 + scaffold &= z > 0 + if not scaffold.any() or not (ligand & ~scaffold).any(): + raise ValueError(f"DiffDec UUID {uuid}: empty scaffold or decoration") + matches = [] + for i in by_smiles[_canonical(row["molecule"])]: + metadata = base.get_metadata(i) + # Preserve exact protein identities when multiple poses coincide. + if "protein_filename" in row and _protein_key( + metadata["pocket_fn"] + ) != _protein_key(row["protein_filename"]): + continue + raw = base.get(i) + absolute = raw.pos.double() + metadata["lig_center"].double() + distances = torch.cdist(positions[ligand], absolute) + distances[z[ligand, None] != raw.z[None, :]] = float("inf") + values, indices = distances.min(dim=1) + if values.max() > tolerance or len(indices.unique()) != len(indices): + continue + # DiffDec pockets are subsets of the supplied pocket10 PDB. Validate + # their actual coordinates too, even when no filename table exists. + pocket_mask = torch.as_tensor(entry["pocket_mask"]).flatten().bool() + if not pocket_mask.any(): + raise ValueError(f"DiffDec UUID {uuid}: empty protein pocket") + pocket = base.get_raw_pocket(i) + pocket_pos = ( + torch.as_tensor(pocket["pos"]).double() + + metadata["lig_center"].double() + ) + pocket_z = torch.tensor( + [ + Chem.GetPeriodicTable().GetAtomicNumber(str(element)) + for element in pocket["elem"] + ] + ) + pocket_distances = torch.cdist(positions[pocket_mask], pocket_pos) + pocket_distances[z[pocket_mask, None] != pocket_z[None, :]] = float("inf") + if pocket_distances.min(dim=1).values.max() > tolerance: + continue + kept = indices[scaffold[ligand]].sort().values.tolist() + matches.append((i, kept)) + if len(matches) != 1: + raise ValueError( + f"DiffDec UUID {uuid}: expected one compatible ligand/pocket pose, " + f"found {len(matches)}; check split, vocabulary and preprocessing" + ) + i, indices = matches[0] + target = base[i] + mask = torch.zeros(target.num_nodes, dtype=torch.bool) + mask[indices] = True + observed = graph_smiles(target.filter_nodes(mask), base.vocab) + if "scaffold" in row and _canonical(observed) != _canonical(row["scaffold"]): + raise ValueError(f"DiffDec UUID {uuid}: mapped scaffold topology differs") + conditions.append( + { + "uuid": uuid, + "base_idx": i, + "atom_indices": indices, + "scaffold": row.get("scaffold", observed), + "true_molecule": row["molecule"], + "protein_filename": row.get( + "protein_filename", base.get_metadata(i)["pocket_fn"] + ), + } + ) + return conditions + + +class DiffDecPocketEvaluationDataset(Dataset): + """Make exactly 100 independent attempts per condition, as used in DiffDec.""" + + def __init__(self, base_dataset, conditions): + if not conditions: + raise ValueError("No scaffold/pocket evaluation conditions") + self.base_dataset, self.conditions = base_dataset, conditions + + def __len__(self): + """Count attempts without deduplicating repeated benchmark conditions.""" + return len(self.conditions) * DIFFDEC_SAMPLES_PER_CONDITION + + def __getitem__(self, index): + """Return a fixed scaffold and its pocket in the original shared frame.""" + if not 0 <= index < len(self): + raise IndexError(index) + condition = self.conditions[index // DIFFDEC_SAMPLES_PER_CONDITION] + base_idx = condition["base_idx"] + target = self.base_dataset[base_idx] + mask = torch.zeros(target.num_nodes, dtype=torch.bool) + mask[condition["atom_indices"]] = True + source, target = evaluation_pair(target.filter_nodes(mask), target) + source.pocket_data = self.base_dataset.get_pocket(base_idx) + return source, target diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/processing.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/processing.py new file mode 100644 index 0000000000000000000000000000000000000000..5d5406704b6789d70b56b26ba90bbc1760e8786c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/processing.py @@ -0,0 +1,196 @@ +"""CrossDocked source download, pocket parsing, and DiffDec ligand processing. + +Adapted from morph at f81996ace95ee45c2e1d23332252d38137b3a918. In particular, +do not run the GEOM validity screen: it discards otherwise usable CrossDocked +ligands. Heavy-atom mode removes *all* hydrogens, including isotopic H. +""" + +from __future__ import annotations + +import os +import tarfile +import tempfile +from pathlib import Path + +import numpy as np +import torch +from rdkit import Chem +from torch_geometric.data import Data + +from morph.utils.rdkit_utils import BOND_IDX_MAP + +RAW_TREE = "crossdocked_pocket10" +RAW_ARCHIVE = f"{RAW_TREE}.tar.gz" +RAW_SPLIT = "split_by_name.pt" +CROSSDOCKED_GDRIVE_IDS = { + RAW_ARCHIVE: "10KGuj15mxOJ2FBsduun2Lggzx0yPreEU", + RAW_SPLIT: "1mycOKpphVBQjxEbpn1AwdpQs8tNVbxKY", +} +BACKBONE_ATOMS = {"N", "CA", "C", "O"} + + +def _download_raw(name: str, destination: Path) -> None: + try: + import gdown # noqa: PLC0415 (optional download dependency) + except ImportError as exc: + raise ImportError( + "CrossDocked download requires `uv sync --extra crossdocked`; " + "alternatively provide a local --source-dir." + ) from exc + with tempfile.TemporaryDirectory(dir=destination.parent) as staging: + temporary = Path(staging) / name + result = gdown.download( + id=CROSSDOCKED_GDRIVE_IDS[name], output=str(temporary), quiet=False + ) + if result is None or not temporary.is_file() or temporary.stat().st_size == 0: + raise OSError(f"Failed to download {name}") + os.replace(temporary, destination) + + +def ensure_crossdocked_raw( + raw_dir: str | Path, source_dir: str | Path | None = None +) -> tuple[Path, Path]: + """Find the pocket tree and official split, downloading only by default. + + A source directory may contain the tree (or archive) and split directly, or + use morph's older layout with the tree beside a ``raw/`` directory. An + explicit source, including MORPH_CROSSDOCKED_RAW, is never downloaded into. + Archive extraction always goes into this dataset's writable raw cache. + """ + source_dir = source_dir or os.environ.get("MORPH_CROSSDOCKED_RAW") + raw = Path(raw_dir) + source = Path(source_dir) if source_dir is not None else raw + directories = [source, source / "raw"] + if source.name in (RAW_TREE, "raw"): + directories.append(source.parent) + + def locate(name): + return next((d / name for d in directories if (d / name).exists()), None) + + tree, archive, split = locate(RAW_TREE), locate(RAW_ARCHIVE), locate(RAW_SPLIT) + if split is None or (tree is None and archive is None): + if source_dir is not None: + raise FileNotFoundError( + f"Expected {RAW_TREE}/ (or {RAW_ARCHIVE}) and {RAW_SPLIT} " + f"under source_dir={source}" + ) + raw.mkdir(parents=True, exist_ok=True) + for name in CROSSDOCKED_GDRIVE_IDS: + if name == RAW_ARCHIVE and tree is not None: + continue + if not (raw / name).is_file(): + _download_raw(name, raw / name) + archive, split = raw / RAW_ARCHIVE, raw / RAW_SPLIT + + if tree is None: + raw.mkdir(parents=True, exist_ok=True) + tree = raw / RAW_TREE + if not tree.is_dir(): + with tempfile.TemporaryDirectory(dir=raw) as staging: + with tarfile.open(archive) as stream: + stream.extractall(staging, filter="data") + extracted = Path(staging) / RAW_TREE + if not extracted.is_dir(): + raise ValueError(f"{archive} does not contain {RAW_TREE}/") + os.replace(extracted, tree) + if not tree.is_dir() or not split.is_file(): + raise FileNotFoundError(f"Incomplete CrossDocked sources under {source}") + return tree, split + + +def parse_pocket(path: str | Path) -> dict | None: + """Read heavy ATOM/HETATM records, retaining residue and backbone metadata. + + Elements come from PDB columns 77-78, as upstream. Chain, residue number, + and insertion code jointly identify a residue. No hydrogenation, docking + preparation, ligand-based cropping, or coordinate scaling happens here. + """ + positions, elements, names, backbone, residues = [], [], [], [], [] + seen = {} + with Path(path).open() as stream: + for line in stream: + if line[:6].strip() not in ("ATOM", "HETATM"): + continue + element = line[76:78].strip().capitalize() + if not element or element in ("H", "D"): + continue + key = (line[21], line[22:27]) + if key not in seen: + seen[key] = len(seen) + positions.append( + (float(line[30:38]), float(line[38:46]), float(line[46:54])) + ) + elements.append(element) + names.append(line[17:20].strip()) + backbone.append(line[12:16].strip() in BACKBONE_ATOMS) + residues.append(seen[key]) + if not positions: + return None + pos = np.asarray(positions, dtype=np.float32) + if not np.isfinite(pos).all(): + raise ValueError(f"Non-finite pocket coordinates: {path}") + return { + "pos": pos, + "elem": elements, + "resname": names, + "backbone": np.asarray(backbone, dtype=bool), + "resid": np.asarray(residues, dtype=np.int64), + } + + +def read_ligand_diffdec( + path: str | Path, hydrogen_mode: str = "implicit" +) -> Chem.Mol | None: + """Sanitize the source SDF, then remove all H or materialize explicit H.""" + if hydrogen_mode not in ("implicit", "explicit"): + raise ValueError("hydrogen_mode must be implicit or explicit") + try: + mol = next(iter(Chem.SDMolSupplier(str(path), removeHs=True))) + if mol is None: + return None + Chem.SanitizeMol(mol) + return ( + Chem.RemoveAllHs(mol) + if hydrogen_mode == "implicit" + else Chem.AddHs(mol, addCoords=True) + ) + except (OSError, ValueError, RuntimeError, StopIteration): + return None + + +def mol_to_data(mol: Chem.Mol) -> Data | None: + """Tensorize sanitized chemistry without GEOM's radical/fragment filters.""" + try: + mol = Chem.Mol(mol) + Chem.Kekulize(mol, clearAromaticFlags=True) + positions = torch.tensor(mol.GetConformer().GetPositions(), dtype=torch.float) + if mol.GetNumAtoms() == 0 or not torch.isfinite(positions).all(): + return None + pairs, labels = [], [] + for bond in mol.GetBonds(): + label = BOND_IDX_MAP.get(bond.GetBondType()) + if label is None: + return None + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + pairs.extend(((i, j), (j, i))) + labels.extend((label, label)) + edges = torch.tensor(pairs, dtype=torch.long).reshape(-1, 2).t() + order = (edges[0] * mol.GetNumAtoms() + edges[1]).argsort() + return Data( + z=torch.tensor([a.GetAtomicNum() for a in mol.GetAtoms()]), + pos=positions, + charges=torch.tensor([a.GetFormalCharge() for a in mol.GetAtoms()]), + edge_index=edges[:, order].contiguous(), + edge_attr=torch.tensor(labels, dtype=torch.long)[order], + smiles=Chem.MolToSmiles(mol) or "", + ) + except (ValueError, RuntimeError): + return None + + +def process_ligand_diffdec( + path: str | Path, hydrogen_mode: str = "implicit" +) -> Data | None: + """Read and tensorize a ligand while preserving heavy-atom order and pose.""" + mol = read_ligand_diffdec(path, hydrogen_mode) + return None if mol is None else mol_to_data(mol) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/scaffold_pocket.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/scaffold_pocket.py new file mode 100644 index 0000000000000000000000000000000000000000..2e5b89b3bb496b590a26938a92103ed5cd87a18b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/pocket/scaffold_pocket.py @@ -0,0 +1,137 @@ +"""Scaffold decoration in a protein pocket, with unconditional replay.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import torch +from torch.utils.data import Dataset + +from morph.dataset.conditional.pocket.build import MANIFEST +from morph.dataset.conditional.pocket.datamodule import ( + PocketFlowMatchingWrapper, + PocketLightningDataModule, + pocket_train_collate_fn, +) +from morph.dataset.conditional.scaffold.wrappers import ( + FlowMatchingDatasetWrapperScaffold, +) + + +def scaffold_cache_directory(dataset, directory): + """Isolate scaffold indices by raw-cache identity and checkpoint element set. + + Two filtered readers can have equal lengths but retain different molecules. + A length-only cache check would silently assign cuts to the wrong ligands. + Token order does not affect cuts, only which elements the reader accepts. + """ + manifest = Path(dataset.processed_dir, MANIFEST).read_bytes() + atoms = sorted(dataset.vocab.atom_tokens) if dataset.vocab is not None else None + digest = hashlib.sha256(manifest + b"\0" + json.dumps(atoms).encode()).hexdigest() + return str(Path(directory) / digest) + + +class ScaffoldPocketWrapper( + FlowMatchingDatasetWrapperScaffold, PocketFlowMatchingWrapper +): + """Use scaffold sources and attach the matching pocket before augmentation. + + The scaffold wrapper resolves the molecule index, then the pocket hook + applies the interpolation's translation. The shared augmentation rotates + scaffold, decoration targets, and pocket together. Evaluation attaches the + pocket to the scaffold in the original ligand-centered frame. + """ + + +class ScaffoldPocketEvaluationDataset(Dataset): + """Repeat fixed scaffold/pocket pairs with IDs for conditional metrics. + + Sample from the enumerated cuts with a local seed, keeping distinct pocket + contexts separate even when their scaffold topologies coincide. Small test + splits use every available pair. IDs identify pairs and generation attempts, + so DDP padding can be deduplicated without discarding independent repeats. + """ + + def __init__(self, dataset, *, n_scaffolds=512, samples_per_scaffold=8, seed=42): + if n_scaffolds < 1 or samples_per_scaffold < 1 or seed < 0: + raise ValueError("Evaluation counts must be positive and seed nonnegative") + if dataset._slicing_offsets is None: + raise ValueError("Pocket scaffold evaluation requires enumerated cuts") + self.dataset = dataset + self.base_dataset = dataset.base_dataset + self.samples_per_scaffold = int(samples_per_scaffold) + self.indices = torch.randperm( + len(dataset), generator=torch.Generator().manual_seed(int(seed)) + )[: int(n_scaffolds)].tolist() + + def __len__(self): + """Count all independent generation attempts.""" + return len(self.indices) * self.samples_per_scaffold + + def __getitem__(self, index): + """Attach the pair ID and a distinct attempt ID to an unchanged pose.""" + if not 0 <= index < len(self): + raise IndexError(index) + pair_index = self.indices[index // self.samples_per_scaffold] + source, target = self.dataset[pair_index] + source.add_system_property("scaffold_id", torch.tensor([pair_index])) + source.add_system_property("sample_id", torch.tensor([index])) + return source, target + + +def scaffold_pocket_train_collate_fn(batch): + """Retain keep masks in mixed batches; replay atoms are entirely editable.""" + samples = [] + for item in batch: + for sample in item if isinstance(item, list) else [item]: + state = sample[0] + if getattr(state, "keep_mask", None) is None: + state = state.clone() + state.add_node_property( + "keep_mask", torch.zeros(state.num_nodes, dtype=torch.long) + ) + sample = (state, *sample[1:]) + samples.append(sample) + return pocket_train_collate_fn(samples) + + +class ScaffoldPocketDataModule(PocketLightningDataModule): + """Filter ligands, select scaffold cuts, and mix in unconditional pretraining.""" + + wrapper_cls = ScaffoldPocketWrapper + train_collate_fn = staticmethod(scaffold_pocket_train_collate_fn) + + def __init__( + self, + *args, + scaffold_groups_dir, + scaffold_kind="libinvent", + enumerate_slicings=True, + eval_panel=None, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.scaffold_groups_dir = scaffold_groups_dir + self.scaffold_kind = scaffold_kind + self.enumerate_slicings = enumerate_slicings + self.eval_panel = eval_panel + + def _wrap_pocket(self, base, stage): + # Scaffold options belong only to the fine-tuning wrapper. Replay keeps + # the ordinary unconditional wrapper and its normal prior/noise recipe. + use_panel = stage != "train" and self.eval_panel is not None + wrapped = self.wrapper_cls( + base_dataset=base, + scaffold_groups_dir=scaffold_cache_directory( + base, self.scaffold_groups_dir + ), + scaffold_kind=self.scaffold_kind, + enumerate_slicings=(self.enumerate_slicings or use_panel) + and stage != "train", + **self._wrapper_kwargs(stage), + ) + if use_panel: + return ScaffoldPocketEvaluationDataset(wrapped, **self.eval_panel) + return wrapped diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/replay.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/replay.py new file mode 100644 index 0000000000000000000000000000000000000000..88724d9e50cc798993f878687ff02fceb26aa9a5 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/replay.py @@ -0,0 +1,179 @@ +"""Sample fine-tuning and unconditional replay independently of corpus sizes.""" + +from __future__ import annotations + +import math + +import hydra +import torch +from torch.utils.data import DataLoader, Dataset, DistributedSampler, Sampler + +from morph.dataset.core.flow_matching_wrapper import ( + FlowMatchingDatasetWrapper, + worker_init_fn, +) +from morph.dataset.core.lightning_datamodule import LightningDataModule + + +class ReplayDataset(Dataset): + """Route sampler indices into the two already-wrapped training datasets.""" + + def __init__(self, finetune, pretrain=None): + self.finetune, self.pretrain = finetune, pretrain + # Existing training metrics measure novelty against the downstream task. + self.base_dataset = finetune.base_dataset + + def __len__(self): + """Count accessible examples across the two sources.""" + return len(self.finetune) + ( + len(self.pretrain) if self.pretrain is not None else 0 + ) + + def __getitem__(self, index): + """Read a source-local example selected by ReplaySampler.""" + if not 0 <= index < len(self): + raise IndexError(index) + if index < len(self.finetune): + return self.finetune[index] + return self.pretrain[index - len(self.finetune)] + + +class ReplaySampler(Sampler): + """Epoch-seeded, distributed sampling with replacement from two sources. + + ``finetune_fraction`` is the probability of selecting the fine-tuning + source, then a molecule is sampled uniformly within that source. All ranks + generate the same stream and take disjoint strided positions. Like a + DistributedSampler, epoch length is rounded up to a multiple of world size. + """ + + def __init__( + self, + finetune_size, + pretrain_size, + finetune_fraction, + epoch_size, + seed=42, + num_replicas=1, + rank=0, + ): + if not 0 <= finetune_fraction <= 1: + raise ValueError("finetune_fraction must be in [0, 1]") + if epoch_size < 1 or num_replicas < 1 or not 0 <= rank < num_replicas: + raise ValueError( + "epoch_size and num_replicas must be positive, with a valid rank" + ) + if finetune_fraction > 0 and finetune_size < 1: + raise ValueError("No compatible fine-tuning molecules remain") + if finetune_fraction < 1 and pretrain_size < 1: + raise ValueError( + "Unconditional replay requires a nonempty pretraining dataset" + ) + self.finetune_size, self.pretrain_size = finetune_size, pretrain_size + self.finetune_fraction = finetune_fraction + self.num_samples = math.ceil(epoch_size / num_replicas) + self.num_replicas, self.rank = num_replicas, rank + self.seed, self.epoch = int(seed), 0 + + def set_epoch(self, epoch): + """Select the next reproducible stream (called by Lightning each epoch).""" + self.epoch = int(epoch) + + def __len__(self): + """Count draws for this rank in one epoch.""" + return self.num_samples + + def __iter__(self): + """Generate this rank's portion of the shared epoch stream.""" + generator = torch.Generator().manual_seed(self.seed + self.epoch) + size = self.num_samples * self.num_replicas + fine = torch.rand(size, generator=generator) < self.finetune_fraction + fine_indices = torch.randint( + max(1, self.finetune_size), (size,), generator=generator + ) + pre_indices = self.finetune_size + torch.randint( + max(1, self.pretrain_size), (size,), generator=generator + ) + indices = torch.where(fine, fine_indices, pre_indices) + return iter(indices[self.rank :: self.num_replicas].tolist()) + + +class ReplayLightningDataModule(LightningDataModule): + """Mix conditional training items with ordinary unconditional flow matching.""" + + def __init__( + self, + *args, + pretrain_dataset=None, + finetune_fraction=0.5, + train_epoch_size=None, + mix_seed=42, + **kwargs, + ): + if not 0 <= finetune_fraction <= 1: + raise ValueError("finetune_fraction must be in [0, 1]") + if train_epoch_size is not None and train_epoch_size < 1: + raise ValueError("train_epoch_size must be positive") + if finetune_fraction < 1 and pretrain_dataset is None: + raise ValueError("pretrain_dataset is required when finetune_fraction < 1") + super().__init__(*args, **kwargs) + self.pretrain_dataset_cfg = pretrain_dataset + self.finetune_fraction = float(finetune_fraction) + self.train_epoch_size, self.mix_seed = train_epoch_size, int(mix_seed) + + def setup(self, stage=None): + """Build replay from pretraining TRAIN only; evaluation remains task-only.""" + super().setup(stage) + if stage not in (None, "fit") or isinstance(self.train_dataset, ReplayDataset): + return + pretrain = None + if self.finetune_fraction < 1: + base = hydra.utils.instantiate( + self.pretrain_dataset_cfg.copy(), + vocab=self.vocab, + distributions=self.distributions, + split="train", + ) + pretrain = FlowMatchingDatasetWrapper( + base_dataset=base, **self._wrapper_kwargs("train") + ) + self.train_dataset = ReplayDataset(self.train_dataset, pretrain) + # Fail during setup, before workers or training are started. + self._replay_sampler() + + def _replay_sampler(self): + fine, pre = self.train_dataset.finetune, self.train_dataset.pretrain + distributed = ( + torch.distributed.is_available() and torch.distributed.is_initialized() + ) + return ReplaySampler( + len(fine), + len(pre) if pre is not None else 0, + self.finetune_fraction, + self.train_epoch_size + or (len(fine) if self.finetune_fraction > 0 else len(pre)), + seed=self.mix_seed, + num_replicas=torch.distributed.get_world_size() if distributed else 1, + rank=torch.distributed.get_rank() if distributed else 0, + ) + + def train_dataloader(self): + """Use replay sampling with Lightning's sampler replacement disabled.""" + nw = self.num_workers.train + return DataLoader( + self.train_dataset, + sampler=self._replay_sampler(), + batch_size=self.batch_size.train // self.n_augmentations, + num_workers=nw, + persistent_workers=nw > 0, + pin_memory=True, + drop_last=True, + collate_fn=self.train_collate_fn, + worker_init_fn=worker_init_fn, + prefetch_factor=self.train_prefetch_factor if nw > 0 else None, + ) + + def _eval_sampler(self, dataset): + if torch.distributed.is_available() and torch.distributed.is_initialized(): + return DistributedSampler(dataset, shuffle=False) + return None diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dc4284df19f2501b98d224b14285ff3d6d1427e7 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/__init__.py @@ -0,0 +1,6 @@ +"""Scaffold-decoration data. + +``cutters`` selects retained atom sets; ``wrappers`` builds flow-matching pairs; +``panel`` preprocesses and repeats diverse evaluation inputs; ``datamodule`` +provides the Lightning data loaders. +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/cutters.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/cutters.py new file mode 100644 index 0000000000000000000000000000000000000000..2f6de489a4cb4590f763dd5ced165a4e3abfda6b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/cutters.py @@ -0,0 +1,433 @@ +"""Select scaffold atom sets in the input molecule's original indexing. + +Murcko keeps the ring/linker core; R-group cuts retain the largest BRICS +fragment; heavy-atom cuts leave hydrogens to generate; linker cuts keep +multiple fragments, including their original hydrogens. The cached atom sets +feed the same keep-mask conditioning and integration path for every kind. +""" + +from __future__ import annotations + +import json +from contextlib import suppress +from functools import lru_cache +from itertools import combinations +from pathlib import Path + +from rdkit import Chem +from rdkit.Chem import BRICS, RWMol +from rdkit.Chem.Scaffolds import MurckoScaffold + +from morph.utils.rdkit_utils import IDX_BOND_MAP + +MAX_SCAFFOLD_AUTOMORPHISMS = 16 +MAX_CUTS = 4 +MAX_RGROUP_ATOMS = 10 +MIN_SCAFFOLD_FRACTION = 0.4 +MAX_SLICINGS = 32 + + +@lru_cache(maxsize=1) +def libinvent_enumerator(): + """Load morph's LibINVENT reactions and DiffDec filtering conditions once.""" + try: + from reinvent_chemistry.library_design import FragmentReactions # noqa: PLC0415 + from reinvent_chemistry.library_design.dtos import ( # noqa: PLC0415 + FilteringConditionDTO, + ) + from reinvent_chemistry.library_design.fragment_reaction_slice_enumerator import ( # noqa: PLC0415, E501 + FragmentReactionSliceEnumerator, + ) + except ImportError as error: + raise ImportError( + "The libinvent scaffold cutter requires the scaffold extra: " + "uv sync --extra scaffold" + ) from error + directory = Path(__file__).parent + smirks = [ + line.strip() + for line in (directory / "libinvent_reactions.smirks").read_text().splitlines() + if line.strip() and not line.startswith("#") + ] + conditions = json.loads( + (directory / "libinvent_filter_conditions.json").read_text() + ) + return FragmentReactionSliceEnumerator( + FragmentReactions().create_reactions_from_smirks(smirks), + [FilteringConditionDTO(**c) for c in conditions["scaffold"]], + [FilteringConditionDTO(**c) for c in conditions["decoration"]], + ) + + +@lru_cache(maxsize=100_000) +def _libinvent_scaffold_smiles(smiles, max_cuts): + # Fail loudly for missing dependencies/resources instead of caching an + # empty dataset. Enumeration failures remain molecule-local as in morph. + enumerator = libinvent_enumerator() + try: + sliced = enumerator.enumerate(Chem.MolFromSmiles(smiles), cuts=max_cuts) + except (ValueError, RuntimeError): + return () + return tuple(sorted({sm.scaffold_smiles for sm in sliced})) + + +def libinvent_indices(mol, max_cuts=MAX_CUTS, max_slicings=MAX_SLICINGS): + """Map LibINVENT reaction scaffolds into the input molecule's atom order.""" + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + cuts, seen = [], set() + for smiles in _libinvent_scaffold_smiles(Chem.MolToSmiles(heavy_mol), max_cuts): + scaffold = Chem.MolFromSmiles(smiles, sanitize=False) + if scaffold is None: + continue + query = Chem.RWMol(scaffold) + for atom in reversed(list(scaffold.GetAtoms())): + if atom.GetAtomicNum() == 0: + query.RemoveAtom(atom.GetIdx()) + query = query.GetMol() + with suppress(ValueError, RuntimeError): + Chem.SanitizeMol(query) + if not query.GetNumAtoms(): + continue + for match in heavy_mol.GetSubstructMatches(query, uniquify=True, maxMatches=8): + key = tuple(sorted(heavy_map[i] for i in match)) + if key not in seen: + seen.add(key) + cuts.append(key) + if len(cuts) >= max_slicings: + return cuts + return cuts + + +def _heavy_submol(mol): + """Heavy-atom copy of ``mol`` plus a new-index -> original-index map. + + Bond-cut rules operate on heavy atoms. Removing hydrogens from a copy + preserves aromatic flags, formal charges and the hydrogen counts needed + to sanitize heteroaromatic rings. The map restores the dataset's indexing. + """ + heavy = [a.GetIdx() for a in mol.GetAtoms() if a.GetAtomicNum() != 1] + if not heavy: + return None, None + try: + out = ( + Chem.Mol(mol) + if len(heavy) == mol.GetNumAtoms() + else Chem.RemoveAllHs(mol, sanitize=False) + ) + Chem.SanitizeMol(out) + except Exception: + return None, None + # RemoveAllHs keeps the relative order of the remaining atoms, so the map is + # positional -- but guard it, since a mismatch would silently mis-index every + # scaffold produced for this molecule. + if out.GetNumAtoms() != len(heavy): + return None, None + return out, heavy + + +def _brics_cuttable(heavy_mol) -> set: + """Bond indices BRICS considers synthetically accessible.""" + cuttable = set() + for (i, j), _ in BRICS.FindBRICSBonds(heavy_mol): + bond = heavy_mol.GetBondBetweenAtoms(int(i), int(j)) + # Ring bonds would open a cycle rather than detach a substituent. + if bond is not None and not bond.IsInRing(): + cuttable.add(bond.GetIdx()) + return cuttable + + +def _slice_on_bonds( + mol, + cuttable_fn, + max_cuts: int, + max_rgroup_atoms: int, + min_scaffold_fraction: float, + max_slicings: int, +) -> list[tuple]: + """Cut 1..``max_cuts`` of the candidate bonds; largest fragment is the core. + + A slicing is rejected when a severed piece exceeds ``max_rgroup_atoms`` + (that is a fragmentation, not a decoration) or the core falls below + ``min_scaffold_fraction`` of the molecule. + """ + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + n_heavy = heavy_mol.GetNumAtoms() + + cuttable = sorted(cuttable_fn(heavy_mol)) + if not cuttable: + return [] + + # Collect per cut-count, then round-robin when truncating. Returning the + # first ``max_slicings`` found instead would be a pure 1-cut sample, since + # enumeration walks k ascending -- multi-R slicings would never survive. + by_cuts, seen = {}, set() + for k in range(1, min(max_cuts, len(cuttable)) + 1): + bucket = by_cuts.setdefault(k, []) + for combo in combinations(cuttable, k): + try: + fragmented = Chem.FragmentOnBonds( + heavy_mol, list(combo), addDummies=False + ) + pieces = Chem.GetMolFrags(fragmented) + except Exception: + continue + if len(pieces) < 2: + continue + core = max(pieces, key=len) + if any(len(p) > max_rgroup_atoms for p in pieces if p is not core): + continue + if len(core) < min_scaffold_fraction * n_heavy: + continue + key = tuple(sorted(core)) + if key in seen: + continue + seen.add(key) + bucket.append(tuple(sorted(heavy_map[i] for i in key))) + + slicings = [] + for rank in range(max(len(b) for b in by_cuts.values()) if by_cuts else 0): + for k in sorted(by_cuts): + if rank < len(by_cuts[k]): + slicings.append(by_cuts[k][rank]) + if len(slicings) >= max_slicings: + return slicings + return slicings + + +def murcko_indices(mol) -> list[tuple]: + """All Bemis-Murcko scaffold matches, as heavy-atom index tuples.""" + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + try: + scaffold_smiles = Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(heavy_mol)) + except Exception: + return [] + if not scaffold_smiles or "." in scaffold_smiles: + return [] + scaffold = Chem.MolFromSmiles(scaffold_smiles) + if scaffold is None: + return [] + matches = heavy_mol.GetSubstructMatches( + scaffold, uniquify=False, maxMatches=MAX_SCAFFOLD_AUTOMORPHISMS + ) + return [tuple(sorted(heavy_map[i] for i in match)) for match in matches] + + +def heavy_indices(mol, **_ignored) -> list[tuple]: + """The whole heavy-atom skeleton as the scaffold -- hydrogen decoration. + + A single match, not an automorphism list: the atoms are taken by index, not + matched through a substructure query, so there is nothing to enumerate. + """ + heavy = tuple(a.GetIdx() for a in mol.GetAtoms() if a.GetAtomicNum() != 1) + # A molecule with no hydrogens has nothing to decorate -- drop it, the same + # way an empty Murcko core drops a molecule from the scaffold task. + return [heavy] if heavy and len(heavy) < mol.GetNumAtoms() else [] + + +def rgroup_indices( + mol, + max_cuts: int = MAX_CUTS, + max_rgroup_atoms: int = MAX_RGROUP_ATOMS, + min_scaffold_fraction: float = MIN_SCAFFOLD_FRACTION, + max_slicings: int = MAX_SLICINGS, +) -> list[tuple]: + """BRICS slicings retaining the largest fragment as the scaffold.""" + return _slice_on_bonds( + mol, + _brics_cuttable, + max_cuts, + max_rgroup_atoms, + min_scaffold_fraction, + max_slicings, + ) + + +def linker_indices( + mol, + min_frag: int = 3, + min_linker: int = 1, + max_linker: int = 12, + max_group: int = 3, + max_slicings: int = MAX_SLICINGS, + **_ignored, +) -> list[tuple]: + """BRICS linker cuts retaining at least two fragments and their hydrogens. + + The removed linker is a connected group of up to ``max_group`` BRICS + fragments with ``min_linker`` to ``max_linker`` heavy atoms. Each retained + component has at least ``min_frag`` heavy atoms. Only acyclic bonds are cut; + rings can occur inside the linker. Returned indices address the original + all-atom molecule, including hydrogens belonging to retained fragments. + """ + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + # BRICS bonds in FULL-atom indices (BRICS runs on the heavy skeleton; the bond + # atoms are heavy). We fragment on the FULL graph so each fragment carries its + # own hydrogens (GEOM is all-atom) and the kept fragments stay intact. + brics = [] + for (i, j), _ in BRICS.FindBRICSBonds(heavy_mol): + b = heavy_mol.GetBondBetweenAtoms(int(i), int(j)) + if b is not None and not b.IsInRing(): + brics.append((heavy_map[int(i)], heavy_map[int(j)])) + if len(brics) < 2: # need ≥2 cuts to isolate a linker between fragments + return [] + + N = mol.GetNumAtoms() + is_heavy = [mol.GetAtomWithIdx(a).GetAtomicNum() > 1 for a in range(N)] + adj = [[] for _ in range(N)] + for b in mol.GetBonds(): + u, v = b.GetBeginAtomIdx(), b.GetEndAtomIdx() + adj[u].append(v) + adj[v].append(u) + cut = {frozenset(e) for e in brics} + + # BRICS fragments = components of the FULL graph with BRICS bonds removed. + frag_of = [-1] * N + frag_atoms: list[set] = [] + for s in range(N): + if frag_of[s] != -1: + continue + comp, stack = set(), [s] + frag_of[s] = len(frag_atoms) + while stack: + x = stack.pop() + comp.add(x) + for y in adj[x]: + if frozenset((x, y)) in cut or frag_of[y] != -1: + continue + frag_of[y] = len(frag_atoms) + stack.append(y) + frag_atoms.append(comp) + nf = len(frag_atoms) + if nf < 3: # need a linker + ≥2 fragments + return [] + + def nheavy(atoms): + return sum(1 for a in atoms if is_heavy[a]) + + fadj = [set() for _ in range(nf)] + for u, v in brics: + if frag_of[u] != frag_of[v]: + fadj[frag_of[u]].add(frag_of[v]) + fadj[frag_of[v]].add(frag_of[u]) + + # Connected fragment subsets (size 1..max_group) — linker candidates. + subsets: set = set() + stack = [frozenset([f]) for f in range(nf)] + while stack: + s = stack.pop() + if s in subsets: + continue + subsets.add(s) + if len(s) >= max_group: + continue + for f in s: + for nb in sorted(fadj[f]): + if nb not in s: + stack.append(s | {nb}) + + keep_sets: set = set() + for S in sorted(subsets, key=lambda s: (len(s), tuple(sorted(s)))): + latoms = set().union(*(frag_atoms[f] for f in S)) + if not (min_linker <= nheavy(latoms) <= max_linker): # size = HEAVY atoms + continue + # Kept fragment components after removing S; each must be ≥ min_frag heavy. + seen: set = set(S) + keep_atoms: set = set() + comps, ok = 0, True + for f in range(nf): + if f in seen: + continue + comps += 1 + catoms, cstack = set(), [f] + seen.add(f) + while cstack: + x = cstack.pop() + catoms |= frag_atoms[x] + for y in fadj[x]: + if y not in seen: + seen.add(y) + cstack.append(y) + if nheavy(catoms) < min_frag: + ok = False + break + keep_atoms |= catoms + if ok and comps >= 2: + keep_sets.add( + tuple(sorted(keep_atoms)) + ) # FULL-atom kept fragments (with H) + if len(keep_sets) >= max_slicings: + break + return sorted(keep_sets) + + +SCAFFOLD_KINDS = { + "murcko": murcko_indices, + "libinvent": libinvent_indices, + "rgroup": rgroup_indices, + "heavy": heavy_indices, + "linker": linker_indices, +} + + +def scaffold_indices(mol, kind: str = "rgroup", **params) -> list[tuple[int, ...]]: + """Return distinct kept atom sets for the selected scaffold convention.""" + try: + cutter = SCAFFOLD_KINDS[kind] + except KeyError: + raise ValueError( + f"unknown scaffold_kind {kind!r}; expected one of {sorted(SCAFFOLD_KINDS)}" + ) from None + # Atom order inside a cut is irrelevant to filter_nodes. Deduplicate + # automorphisms so evaluation counts each retained subgraph only once. + return list(dict.fromkeys(tuple(sorted(cut)) for cut in cutter(mol, **params))) + + +def _atom_indices_for_data( + data, scaffold_kind: str = "rgroup" +) -> list[tuple[int, ...]]: + """Return the chosen cutter's scaffold atom sets for one molecule. + + The molecule is reconstructed from stored atom and bond tensors so the + matches stay in the dataset's atom-index space. + """ + mol = mol_from_data(data) + return [] if mol is None else scaffold_indices(mol, scaffold_kind) + + +def mol_from_data(data): + """Reconstruct a sanitized molecule in the stored atom-index space.""" + rwmol = RWMol() + charges = getattr(data, "charges", None) + for idx, z_val in enumerate(data.z.tolist()): + atom = Chem.Atom(int(z_val)) + if charges is not None: + atom.SetFormalCharge(int(charges[idx])) + rwmol.AddAtom(atom) + for k in range(data.edge_index.shape[1]): + i, j = int(data.edge_index[0, k]), int(data.edge_index[1, k]) + if i < j: + rwmol.AddBond(i, j, IDX_BOND_MAP[int(data.edge_attr[k])]) + try: + mol = rwmol.GetMol() + Chem.SanitizeMol(mol) + except Exception: + return None + return mol + + +def compute_scaffold_atom_indices( + dataset, scaffold_kind: str = "rgroup" +) -> list[list[tuple[int, ...]]]: + """Return alternative scaffold atom sets for every dataset molecule.""" + return [ + _atom_indices_for_data(dataset.get(idx), scaffold_kind) + for idx in range(len(dataset)) + ] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/datamodule.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..15539da30823fe40d338380d9ef5b3d77a4d6897 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/datamodule.py @@ -0,0 +1,63 @@ +"""Lightning datamodule for scaffold decoration.""" + +from __future__ import annotations + +import hydra + +from morph.dataset.conditional.scaffold.panel import ScaffoldEvaluationDataset +from morph.dataset.conditional.scaffold.wrappers import ( + FlowMatchingDatasetWrapperScaffold, +) +from morph.dataset.core.lightning_datamodule import LightningDataModule + + +class ScaffoldLightningDataModule(LightningDataModule): + """Wrap base datasets using a selectable retained-subgraph convention.""" + + wrapper_cls = FlowMatchingDatasetWrapperScaffold + + def __init__( + self, + *args, + scaffold_groups_dir: str, + scaffold_kind: str = "rgroup", + enumerate_slicings: bool = False, + eval_panel=None, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._scaffold_groups_dir = scaffold_groups_dir + self._scaffold_kind = scaffold_kind + self._enumerate_slicings = bool(enumerate_slicings) + self._eval_panel = eval_panel + + def _wrap_dataset(self, dataset_cfg, stage: str): + if stage == "train" or self._eval_panel is None: + return super()._wrap_dataset(dataset_cfg, stage) + dataset = hydra.utils.instantiate( + dataset_cfg.copy(), + vocab=self.vocab, + distributions=self.distributions, + split=stage, + ) + return ScaffoldEvaluationDataset( + dataset, + scaffold_groups_dir=self._scaffold_groups_dir, + scaffold_kind=self._scaffold_kind, + **self._eval_panel, + ) + + def _wrapper_kwargs(self, stage: str) -> dict: + kwargs = super()._wrapper_kwargs(stage) + kwargs["scaffold_groups_dir"] = self._scaffold_groups_dir + kwargs["scaffold_kind"] = self._scaffold_kind + kwargs["enumerate_slicings"] = self._enumerate_slicings and stage != "train" + return kwargs + + def val_dataloader(self): + """Retain the final partial batch when evaluating every scaffold.""" + return self._eval_dataloaders( + self.val_datasets, + "val", + drop_last=self._eval_panel is None and not self._enumerate_slicings, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/libinvent_filter_conditions.json b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/libinvent_filter_conditions.json new file mode 100644 index 0000000000000000000000000000000000000000..07ebe9792f1b220b9d7d5e41f38080f2318da561 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/libinvent_filter_conditions.json @@ -0,0 +1,33 @@ +{ + "scaffold": [{ + "name":"ring_count", + "min": 1 + }], + "decoration": [ + { + "name":"molecular_weight", + "max": 300 + }, + { + "name":"hydrogen_bond_acceptors", + "max": 3 + }, + { + "name":"hydrogen_bond_donors", + "max": 3 + }, + { + "name":"clogp", + "max": 3 + }, + { + "name":"rotatable_bonds", + "max": 3 + }, + { + "name": "heavy_atom_count", + "max": 10, + "min": 1 + } + ] +} \ No newline at end of file diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/libinvent_reactions.smirks b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/libinvent_reactions.smirks new file mode 100644 index 0000000000000000000000000000000000000000..852a57c5d02363c38ed8ecadaea1ca951b2d11e6 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/libinvent_reactions.smirks @@ -0,0 +1,37 @@ +[#6;$(C[C;$(C([#6]))]):4]-!@[N;$([NH1;D2](C)C);!$(N-[#6]=[*]);$(N([C])):3]>>[#6:4][*].[N:3][*] +[C;$([CH;$(C([#6])[#6])]),$([CH2;$(C[#6])]):1]-!@[N;$(N(C=O)C=O):2]>>[*:1][*].[*:2][*] +[C;$([CH;$(C([#6])[#6])]),$([CH2;$(C[#6])]):1]-!@[O;$(Oc1ccccc1):2]>>[*:1][*].[*:2][*] +[C;$([CH;$(C([#6])[#6])]),$([CH2;$(C[#6])]):1]-!@[N;$(N([#6])S(=O)=O):2]>>[*:1][*].[*:2][*] +[S;$(S(=O)(=O)[C,N]):1]-!@[N+0;$(NC):2]>>[*:1][*].[*:2][*] +[N;$(N-[#6]):3]-!@[C;$(C=O):1]-!@[N+0;$(N[#6]);!$(N=*);!$([N-]);!$(N#*);!$([ND1]);!$(N[O,N]):2]>>[*:1][*].[*:2][*] +[#6;!$([#6]=*);!$([#6]~[O,N,S]);$([#6]~[#6]):1][c:2]>>[*:2][*].[*:1][*] +[#6;$(C=[#6!H0]):1][C;$(C#N):2]>>[*:1][*].[*][*:2] +[#6:1]([N+]([O-])=O)=[#6:2]>>[*:1][*][N+]([O-])=O.[*:2][*] +[#6;!$(A(A=[O,S]));!$(A=*);!$([A-]);!$(A~[P,S,O,N]):3][C:1](=[#7:2])[N!H0;!$(A(A=[O,S]));!$(A=*);!$([A-]);!$(A~[P,S,O,N]):4]>>[#6:3][C:1]([*])=[N:2].[#7!H0:4][*] +[#6;!$(C(C=*)(C=*));!$([#6]~[O,N,S]);$([#6]~[#6]):1][C:2](=[O:3])[N;D2;$(N(C=[O,S]));!$(N~[O,P,S,N]):4][#6;!$(C=*);!$([#6](~[O,N,S])N);$([#6]~[#6]):5]>>[#6:1][C:2](=[O:3])[*].[*][N:4][#6:5] +[#6;!R;!$(C=*);!$([#6]~[O,N,S]);$([#6]~[#6]):1][#6;!R;!$(C=*);!$([#6]~[O,N,S]);$([#6]~[#6]):2]>>[#6:1][*].[#6:2][*] +[N;!H0:1]([C:2]([#7:5][#6:6])=[#8:3])[#6:4]>>[#8:3]=[C:2]([#7:1][#6:4])[*].[*][#7:5][#6:6] +[#6;!$(C(C=*)(C=*));!$([#6]~[O,N,S]);$([#6]~[#6]):1][C:2](=[O:3])[N;D2;$(N(C=[O,S]));!$(N~[O,P,S,N]):4][#6;!$(C=*);!$([#6](~[O,N,S])N);$([#6]~[#6]):5]>>[#6:1][C:2](=[O:3])[*].[*][N:4][#6:5] +[#6;!$([#6]=*);!$([#6]~[O,N,S,P]);$([#6]~[#6]):2]-!@[#6;!$([#6]=*);!$([#6]~[O,N,S,P]);$([#6]~[#6]):1]>>[#6;$([#6]~[#6]);!$([#6]~[S,N,O,P]):1][*].[*][#6;$([#6]~[#6]);!$([#6]~[S,N,O,P]):2] +[CH2;$([#6]~[#6]):4]-!@[O:3]-!@[#6;$([#6]~[#6]);!$([#6]=O):2]>>[#6;$([#6]~[#6]);!$([#6]=O):2][#8][*].[*][#6;H2;$([#6]~[#6]):4] +[*;$(c2aaaaa2),$(c2aaaa2):1]-!@[*;$(c2aaaaa2),$(c2aaaa2):2]>>[*:1][*].[*:2][*] +[*;$(c2aaaaa2),$(c2aaaa2):4]/[#6:1]=!@[#6:2]/[*;$(c2aaaaa2),$(c2aaaa2):3]>>[#6;c,$(C(=O)O),$(C#N):3][#6;H1:2]=[#6;H1:1][*].[#6;$([#6]=[#6]),$(c:c):4][*] +[#6:4][#6;H0:1]=!@[#6:2]([#6:5])[#6:3]>>[#6;c,$(C(=O)O),$(C#N):3][#6:2]([#6:5])=[#6;$([#6][#6]):1][*].[#6;$([#6]=[#6]),$(c:c):4][*] +[*;$(c);$(C=C-[#6]),$(c):1]-!@[*;$(c):2]>>[#6;$(C=C-[#6]),$(c):1][*].[*][*;$(c):2] +[C;$(C([#6])[#6]):1]([#6:5])([#6:2])([O;H1:3])[#6;!R:4]>>[#6:2][#6:1](*)([#6:5])[O:3].[*][#6:4] +[#6;$(C=C-[#6]),$(c:c):1]-!@[C;$(C#CC):2]>>[#6;$(C=C-[#6]),$(c:c):1][*].[*][CH1;$(C#CC):2] +[c;$(c1:[c,n]:[c,n]:[c,n]:[c,n]:[c,n]:1):1]-!@[N;$(NC)&!$(N=*)&!$([N-])&!$(N#*)&!$([ND1])&!$(N[O])&!$(N[C,S]=[S,O,N]),H2&$(Nc1:[c,n]:[c,n]:[c,n]:[c,n]:[c,n]:1):2]>>[*][c;$(c1:[c,n]:[c,n]:[c,n]:[c,n]:[c,n]:1):1].[*][N:2] +[*;!$(c1ccccc1);$(c1[n,c]c[n,c]c[n,c]1):1]-!@[N;$(NC);!$(N=*);!$([N-]);!$(N#*);!$([ND3]);!$([ND4]);!$(n[c,O]);!$(N[C,S]=[S,O,N]):2]>>[*;!$(c1ccccc1);$(c1[n,c]c[n,c]c[n,c]1):1][*].[*][N:2] +[*;$(c1c(N(~O)~O)cccc1):1]-!@[N;$(NC);!$(N=*);!$([N-]);!$(N#*);!$([ND1]);!$(N[O]);!$(N[C,S]=[S,O,N]):2]>>[*;$(c1c(N(~O)~O)cccc1):1][*].[*][N:2] +[*;$(c1ccc(N(~O)~O)cc1):1]-!@[N;$(NC);!$(N=*);!$([N-]);!$(N#*);!$([ND1]);!$(N[O]);!$(N[C,S]=[S,O,N]):2]>>[*;$(c1ccc(N(~O)~O)cc1):1][*].[*][N:2] +[#6;!$([#6]=*);!$([#6]~[O,N,S]);$([#6]~[#6]):1][#6;!$([#6]=*);!$([#6]~[O,N,S]);$([#6]~[#6]):2]>>[#6;!$([#6]=*);!$([#6]~[O,N,S]);$([#6]~[#6]):1][*].[#6;!$([#6]=*);!$([#6]~[O,N,S]);$([#6]~[#6]):2][*] +[C:2]([#7;!D4:1])(=[O:3])[#6:4]>>[#7:1][*].[C,$(C=O):2](=[O:3])([*])[#6:4] +[#6;$(C(=O)):1][#7,#8,#16:2]>>[*:1][*].[*:2][*] +[O:2]=[#6:1][#7:5]>>[O:2]=[#6:1][*].[N:5][*] +[#6;$(C=[O]):1][#8,#16:2]>>[*:1][*].[*][*:2] +[N;!$(n1****1);!$(n1*****1);!$(N=*);!$(N(A=A));!$([N-]);!$(N~[O,P,S,N]):1]-!@[#6;!$(C=*);!$(C(A=A));!$([C-]);!$(C~[O,P,S]):2]>>[N:1][*].[*][#6:2] +[#6:8][O:7][C:5](=[O:6])[C:4]([C:2](=[O:3])[#6:1])[#6:9]>>[#6:1][C:2]([C:4]([*])[C:5]([O:7][#6:8])=[O:6])=[O:3].[#6:9][*] +[#6:1][C:2]([#6:7])[C:3](=[O:4])[O:5][#6:6]>>[C;!H0:2]([*])([C:3]([O:5][#6:6])=[O:4])[#6:1].[#6:7][*] +[N;!$(n1****1);!$(n1*****1);!$(N(A=A));!$(N=*);!$([N-]);!$(N~[O,P,S,N]):1][*;$(c1aaaaa1),$(c1aaaa1);!$(C=*);!$(C(A=A));!$([C-]);!$(C~[O,P,S]):2]>>[N:1][*].[#6:2][*] +[C:3]([C:1]([#8:5][#6:6])=[O:2])[#6:7]=[O:8]>>[#6:6][#8:5][C:1](=[O:2])[C!H0:3][*].[#6:7](=[O:8])[*] +[N+:1]([#6:2])([#6:4])([#6:5])[#6:3]>>[N;!$(N=*);!$([N-]);!$(N~[O,P,S,N]):1]([#6:2])([#6:3])([*])[#6:4].[*][#6:5] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/lmdb.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..e1416ca74b32057f8698a23d47ab7c3ada4822cb --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/lmdb.py @@ -0,0 +1,163 @@ +"""Lazy LMDB reader for per-molecule scaffold atom matches.""" + +from __future__ import annotations + +import fcntl +import os +import pickle +from contextlib import contextmanager +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +from morph.dataset.conditional.scaffold.cutters import SCAFFOLD_KINDS +from morph.dataset.core.lmdb import open_read_env + +if TYPE_CHECKING: + import lmdb + +_META_N = b"meta:n" +_META_NONEMPTY = b"meta:scaffold_nonempty_indices" + + +@contextmanager +def scaffold_cache_lock(path): + """Serialize first-use builders across processes sharing a cache directory.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with Path(f"{path}.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + +def _kind_suffix(scaffold_kind: str) -> str: + if scaffold_kind not in SCAFFOLD_KINDS: + raise ValueError( + f"unknown scaffold_kind {scaffold_kind!r}; " + f"expected one of {sorted(SCAFFOLD_KINDS)}" + ) + # Existing caches without a suffix contain Murcko matches. + return "" if scaffold_kind == "murcko" else f"_{scaffold_kind}" + + +def scaffold_lmdb_path( + scaffold_groups_dir: str, stage: str, scaffold_kind: str = "rgroup" +) -> str: + """Return the scaffold LMDB path for this split and cutter.""" + return os.path.join( + scaffold_groups_dir, f"scaffold_{stage}{_kind_suffix(scaffold_kind)}.lmdb" + ) + + +def scaffold_pt_path( + scaffold_groups_dir: str, stage: str, scaffold_kind: str = "rgroup" +) -> str: + """Return the fallback cache path, preserving existing Murcko filenames.""" + return os.path.join( + scaffold_groups_dir, + f"scaffold_groups_{stage}{_kind_suffix(scaffold_kind)}.pt", + ) + + +def _key(prefix: str, i: int) -> bytes: + return f"{prefix}:{int(i)}".encode() + + +def compute_filter_metadata(atom_indices) -> list[int]: + """Return indices whose molecules have a non-empty scaffold match.""" + return [i for i, matches in enumerate(atom_indices) if matches] + + +class _LazyField: + """Subscriptable view of one LMDB key prefix.""" + + __slots__ = ("_cache", "_default", "_prefix") + + def __init__(self, cache: ScaffoldLMDBCache, prefix: str, default): + self._cache = cache + self._prefix = prefix + self._default = default + + def __getitem__(self, i): + return self._cache._read(self._prefix, i, self._default) + + +_READ_ENVS: dict[str, lmdb.Environment] = {} +_READ_TXNS: dict[tuple[str, int], object] = {} + + +def _shared_read_env(path: str) -> lmdb.Environment: + env = _READ_ENVS.get(path) + if env is None: + env = open_read_env(path) + _READ_ENVS[path] = env + return env + + +def _shared_read_txn(path: str): + key = (path, os.getpid()) + txn = _READ_TXNS.get(key) + if txn is None: + txn = _shared_read_env(path).begin() + _READ_TXNS[key] = txn + return txn + + +class ScaffoldLMDBCache: + """Lazy reader over a scaffold LMDB shared by dataloader workers.""" + + def __init__(self, lmdb_path: str): + self.lmdb_path = lmdb_path + with _shared_read_env(lmdb_path).begin() as txn: + self.n = pickle.loads(txn.get(_META_N)) + self._filtered = np.asarray( + pickle.loads(txn.get(_META_NONEMPTY)), dtype=np.int64 + ) + self.atom_indices = _LazyField(self, "ai", []) + + def _read(self, prefix: str, i, default): + raw = _shared_read_txn(self.lmdb_path).get(_key(prefix, int(i))) + return default if raw is None else pickle.loads(raw) + + def filtered_indices(self): + """Indices of source molecules with cached scaffold annotations.""" + return self._filtered + + def smiles(self, index): + """Reuse optional SMILES metadata without decoding conformer tensors.""" + return self._read("smi", index, None) + + +class _DictScaffoldCache: + """Adapter exposing the LMDB cache interface over a legacy ``.pt`` dict.""" + + def __init__(self, data: dict): + self.atom_indices = data["scaffold_atom_indices"] + self.n = len(self.atom_indices) + self._filtered = np.asarray( + compute_filter_metadata(self.atom_indices), dtype=np.int64 + ) + + def filtered_indices(self): + return self._filtered + + +def load_scaffold_cache( + base_dataset, + scaffold_groups_dir: str, + stage: str, + *, + legacy_loader, + scaffold_kind: str = "rgroup", +): + """Load the lazy LMDB cache, falling back to the legacy ``.pt`` cache.""" + lmdb_path = scaffold_lmdb_path(scaffold_groups_dir, stage, scaffold_kind) + if os.path.isdir(lmdb_path): + return ScaffoldLMDBCache(lmdb_path) + return _DictScaffoldCache( + legacy_loader(base_dataset, scaffold_groups_dir, stage, scaffold_kind) + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/panel.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/panel.py new file mode 100644 index 0000000000000000000000000000000000000000..250df72ff07306cab9d93ae9456ae9e6d616e1f1 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/panel.py @@ -0,0 +1,250 @@ +"""Precomputed diverse scaffold panels, independent of training-time sampling.""" + +from __future__ import annotations + +import json +import os +import random +import tempfile +from collections import defaultdict +from pathlib import Path + +import torch +from rdkit import Chem +from rdkit.Chem import rdFingerprintGenerator +from rdkit.SimDivFilters.rdSimDivPickers import MaxMinPicker +from torch.utils.data import Dataset +from tqdm import tqdm + +from morph.dataset.conditional.scaffold.cutters import mol_from_data, scaffold_indices +from morph.dataset.conditional.scaffold.lmdb import ( + load_scaffold_cache, + scaffold_cache_lock, +) +from morph.dataset.conditional.scaffold.wrappers import ( + _load_or_build_scaffold_cache, + evaluation_pair, +) + + +def panel_path(directory, split, kind, n_scaffolds=512, seed=42): + """Name panels by split, cutter, budget and selection seed.""" + return Path(directory) / f"{split}_{kind}_{n_scaffolds}_seed{seed}.json" + + +def panel_metadata(dataset, kind, n_scaffolds, seed): + """Identify the record ordering and selection protocol for cache validation.""" + path = getattr(dataset, "lmdb_path", None) + storage = Path(path) / "data.mdb" if path else None + return { + "version": 1, + "split": dataset.split, + "dataset": str(Path(path).resolve()) if path else type(dataset).__name__, + "records": len(dataset), + "storage": ( + [storage.stat().st_size, storage.stat().st_mtime_ns] if storage else None + ), + "kind": kind, + "n_scaffolds": n_scaffolds, + "seed": seed, + } + + +def scaffold_smiles(mol, indices): + """Canonical scaffold topology, ignoring H spelling and stereochemistry.""" + # Cutting a substituted aromatic N can require a new implicit H. Spell the + # fragment with explicit bond orders so RDKit can infer that valence before + # perceiving aromaticity again (e.g. N-substituted quinazolinones). + mol = Chem.Mol(mol) + Chem.Kekulize(mol, clearAromaticFlags=True) + fragment = Chem.MolFromSmiles( + Chem.MolFragmentToSmiles(mol, atomsToUse=list(indices), isomericSmiles=False) + ) + if fragment is None: + raise ValueError("Scaffold cut does not produce a parseable SMILES") + return Chem.MolToSmiles(Chem.RemoveHs(fragment), isomericSmiles=False) + + +def build_panel(dataset, cache, kind, *, n_scaffolds=512, seed=42): + """Select diverse topologies and one uniform random conformer per molecule. + + Reservoir sampling removes conformer-count bias. Scaffold selection uses + MaxMin Tanimoto distance on Morgan radius-3/2048-bit fingerprints. Repeated + conformers never enter the diversity pool. All randomness is local and seeded. + """ + if n_scaffolds < 1 or seed < 0: + raise ValueError("n_scaffolds must be positive and seed non-negative") + rng = random.Random(seed) + canonical = {} + representatives = {} + counts = defaultdict(int) + read_smiles = getattr(cache, "smiles", lambda _index: None) + for index in tqdm(cache.filtered_indices(), desc=f"{dataset.split} molecules"): + index = int(index) + raw = read_smiles(index) or dataset.get(index).smiles + if raw not in canonical: + mol = Chem.MolFromSmiles(raw) + if mol is None: + raise ValueError(f"Invalid dataset SMILES at record {index}") + canonical[raw] = Chem.MolToSmiles(Chem.RemoveHs(mol)) + smi = canonical[raw] + counts[smi] += 1 + if rng.randrange(counts[smi]) == 0: + representatives[smi] = index + + candidates = defaultdict(list) + for smi in tqdm(sorted(representatives), desc="Scaffold topologies"): + mol = Chem.MolFromSmiles(smi) + if getattr(dataset, "hydrogen_mode", "explicit") == "explicit": + mol = Chem.AddHs(mol) + for cut in scaffold_indices(mol, kind): + key = scaffold_smiles(mol, cut) + if smi not in candidates[key]: + candidates[key].append(smi) + keys = sorted(candidates) + if len(keys) < n_scaffolds: + raise ValueError( + f"Requested {n_scaffolds} scaffolds but {dataset.split} has {len(keys)}" + ) + generator = rdFingerprintGenerator.GetMorganGenerator(radius=3, fpSize=2048) + fingerprints = [generator.GetFingerprint(Chem.MolFromSmiles(s)) for s in keys] + picks = MaxMinPicker().LazyBitVectorPick( + fingerprints, len(keys), n_scaffolds, seed=seed + ) + entries = [] + for pick in picks: + key = keys[pick] + smi = rng.choice(candidates[key]) + index = representatives[smi] + # SMILES atom order differs from stored atom order. Resolve the selected + # topology against the actual conformer, never reuse SMILES atom indices. + mol = mol_from_data(dataset.get(index)) + cuts = [ + cut + for cut in scaffold_indices(mol, kind) + if scaffold_smiles(mol, cut) == key + ] + if not cuts: + raise ValueError(f"Selected scaffold does not match record {index}") + entries.append( + { + "base_idx": index, + "atom_indices": list(rng.choice(cuts)), + "molecule_smiles": smi, + "scaffold_smiles": key, + } + ) + return { + "metadata": panel_metadata(dataset, kind, n_scaffolds, seed), + "candidate_molecules": len(representatives), + "candidate_scaffolds": len(keys), + "entries": entries, + } + + +def write_panel(panel, path): + """Atomically publish the small JSON panel after preprocessing completes.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as out: + temporary = out.name + json.dump(panel, out, indent=2) + out.write("\n") + try: + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _read_panel(path, metadata): + if path.is_file(): + panel = json.loads(path.read_text()) + if panel["metadata"] != metadata: + raise ValueError( + f"Stale scaffold evaluation panel: {path}. " + "Rebuild with scripts/preprocess_scaffold.py --force." + ) + entries = panel["entries"] + if len(entries) != metadata["n_scaffolds"] or len( + {row["scaffold_smiles"] for row in entries} + ) != len(entries): + raise ValueError( + f"Invalid scaffold panel size or duplicate scaffolds: {path}" + ) + return panel + return None + + +def _load_or_build_panel(dataset, path, metadata, scaffold_groups_dir, *, force=False): + if not force and (panel := _read_panel(path, metadata)) is not None: + return panel + with scaffold_cache_lock(path): + if not force and (panel := _read_panel(path, metadata)) is not None: + return panel + print(f"Preparing scaffold evaluation panel: {path}", flush=True) + cache = load_scaffold_cache( + dataset, + scaffold_groups_dir, + dataset.split, + legacy_loader=_load_or_build_scaffold_cache, + scaffold_kind=metadata["kind"], + ) + panel = build_panel( + dataset, + cache, + metadata["kind"], + n_scaffolds=metadata["n_scaffolds"], + seed=metadata["seed"], + ) + write_panel(panel, path) + return panel + + +class ScaffoldEvaluationDataset(Dataset): + """Prepare a missing panel, then repeat its geometries with stable scoring IDs.""" + + def __init__( + self, + dataset, + *, + scaffold_groups_dir, + scaffold_kind="rgroup", + directory=None, + n_scaffolds=512, + seed=42, + samples_per_scaffold=8, + force=False, + ): + if samples_per_scaffold < 1 or n_scaffolds < 1 or seed < 0: + raise ValueError("Scaffold counts must be positive and seed non-negative") + if directory is None: + directory = Path(scaffold_groups_dir) / "eval_panels" + self.path = panel_path( + directory, dataset.split, scaffold_kind, n_scaffolds, seed + ) + metadata = panel_metadata(dataset, scaffold_kind, n_scaffolds, seed) + panel = _load_or_build_panel( + dataset, self.path, metadata, scaffold_groups_dir, force=force + ) + self.entries = panel["entries"] + self.base_dataset = dataset + self.samples_per_scaffold = samples_per_scaffold + + def __len__(self): + """Return the total generation budget, including repeats.""" + return len(self.entries) * self.samples_per_scaffold + + def __getitem__(self, index): + """Return one fixed scaffold/target pair and its global attempt ID.""" + if not 0 <= index < len(self): + raise IndexError(index) + scaffold_id = index // self.samples_per_scaffold + entry = self.entries[scaffold_id] + target = self.base_dataset[entry["base_idx"]] + keep = torch.zeros(target.num_nodes, dtype=torch.bool) + keep[entry["atom_indices"]] = True + source, target = evaluation_pair(target.filter_nodes(keep), target) + source.add_system_property("scaffold_id", torch.tensor([scaffold_id])) + source.add_system_property("sample_id", torch.tensor([index])) + return source, target diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/wrappers.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/wrappers.py new file mode 100644 index 0000000000000000000000000000000000000000..68c3a2973d14226e86ad33d41981dc3ea863c56b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/conditional/scaffold/wrappers.py @@ -0,0 +1,226 @@ +"""Flow-matching dataset wrapper for scaffold decoration.""" + +from __future__ import annotations + +import os +import random +import tempfile + +import numpy as np +import torch + +from morph.dataset.conditional.scaffold.cutters import compute_scaffold_atom_indices +from morph.dataset.conditional.scaffold.lmdb import ( + load_scaffold_cache, + scaffold_cache_lock, + scaffold_pt_path, +) +from morph.dataset.core.flow_matching_wrapper import FlowMatchingDatasetWrapper +from morph.utils.graph_math import build_fully_connected_edge_index + + +def rebuild_atomic_with_edges(data, edge_index, edge_types): + """Type-preserving copy of ``data`` with fully replaced connectivity.""" + out = data.clone() + out.edge_index = edge_index.long() + out.edge_types = edge_types.long() + return out + + +def _load_or_build_scaffold_cache( + base_dataset, + scaffold_groups_dir: str, + stage: str, + scaffold_kind: str = "rgroup", +) -> dict: + """Load or lazily build scaffold atom sets for this split and cutter.""" + if scaffold_groups_dir is None: + raise ValueError("scaffold_groups_dir must be provided") + path = scaffold_pt_path(scaffold_groups_dir, stage, scaffold_kind) + + def read_cache(): + cache = torch.load(path, weights_only=False) if os.path.exists(path) else {} + matches = cache.get("scaffold_atom_indices") + if ( + matches is not None + and len(matches) == len(base_dataset) + and (len(matches) == 0 or isinstance(matches[0], list)) + and cache.get("scaffold_kind", scaffold_kind) == scaffold_kind + ): + return cache + return None + + cache = read_cache() + if cache is not None: + return cache + with scaffold_cache_lock(path): + # Another rank may have finished while this process waited for the lock. + cache = read_cache() + if cache is not None: + return cache + cache = { + "scaffold_kind": scaffold_kind, + "scaffold_atom_indices": compute_scaffold_atom_indices( + base_dataset, scaffold_kind + ), + } + # A reader must never observe a partly written first-run cache. + with tempfile.NamedTemporaryFile( + dir=scaffold_groups_dir, suffix=".pt", delete=False + ) as tmp: + tmp_path = tmp.name + try: + torch.save(cache, tmp_path) + os.replace(tmp_path, path) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + return cache + + +class FlowMatchingDatasetWrapperScaffold(FlowMatchingDatasetWrapper): + """Build retained-subgraph → full-molecule flow-matching examples. + + ``keep_mask`` marks the scaffold atoms in the interpolated state. Scaffold + atoms are placed first by the scaffold-aware interpolator. + Training draws a random cut per visit. Evaluation can enumerate all + distinct molecule/scaffold pairs with ``enumerate_slicings=True``. + """ + + def __init__( + self, + base_dataset, + distributions, + interpolator, + vocab, + representation=None, + n_atoms_strategy="uniform", + uniform_n_atoms_padding: int = 5, + time_dist=None, + stage="train", + rotate: bool = False, + n_augmentations: int = 1, + scaffold_groups_dir: str | None = None, + scaffold_kind: str = "rgroup", + enumerate_slicings: bool = False, + ): + base_kwargs = dict( + n_atoms_strategy=n_atoms_strategy, + uniform_n_atoms_padding=uniform_n_atoms_padding, + time_dist=time_dist, + stage=stage, + rotate=rotate, + n_augmentations=n_augmentations, + ) + if representation is not None: + base_kwargs["representation"] = representation + super().__init__( + base_dataset, + distributions, + interpolator, + vocab, + **base_kwargs, + ) + cache = load_scaffold_cache( + base_dataset, + scaffold_groups_dir, + stage, + legacy_loader=_load_or_build_scaffold_cache, + scaffold_kind=scaffold_kind, + ) + self._scaffold_atom_indices = cache.atom_indices + self._filtered_indices = cache.filtered_indices() + self._slicing_offsets = None + if enumerate_slicings and stage != "train": + # One offset per molecule avoids millions of Python (mol, cut) + # pairs for large evaluation sets. Deduplicate legacy automorphisms. + counts = [ + len(self._scaffold_choices(int(i))) for i in self._filtered_indices + ] + self._slicing_offsets = np.concatenate( + (np.zeros(1, dtype=np.int64), np.cumsum(counts, dtype=np.int64)) + ) + + def __len__(self): + """Count molecules, or distinct molecule/scaffold evaluation pairs.""" + if self._slicing_offsets is not None: + return int(self._slicing_offsets[-1]) + return len(self._filtered_indices) + + def _scaffold_choices(self, base_idx: int) -> list[tuple[int, ...]]: + return list( + dict.fromkeys( + tuple(sorted(cut)) for cut in self._scaffold_atom_indices[base_idx] + ) + ) + + def _select_source(self, base_idx: int, target, slicing_idx: int | None = None): + choices = self._scaffold_choices(base_idx) + scaffold_indices = ( + random.choice(choices) if slicing_idx is None else choices[slicing_idx] + ) + mask = torch.zeros(target.num_nodes, dtype=torch.bool) + mask[torch.as_tensor(scaffold_indices, dtype=torch.long)] = True + source = target.filter_nodes(mask) + kept = sorted(int(i) for i in scaffold_indices) + pairs = [(i, target_idx) for i, target_idx in enumerate(kept)] + return source, pairs + + def __getitem__(self, index): + """Build an interpolated training item or a kept-subgraph evaluation pair.""" + index = int(index) + if index < 0: + index += len(self) + if not 0 <= index < len(self): + raise IndexError(index) + slicing_idx = None + if self._slicing_offsets is not None: + molecule_idx = int( + np.searchsorted(self._slicing_offsets, index, side="right") - 1 + ) + slicing_idx = int(index - self._slicing_offsets[molecule_idx]) + index = molecule_idx + base_idx = int(self._filtered_indices[index]) + target = self.base_dataset[base_idx] + source, scaffold_pairs = self._select_source(base_idx, target, slicing_idx) + + if self.stage == "train": + t = self.time_dist.sample((1,)).squeeze(0) + t = torch.clamp(t, min=0.0, max=1 - 1e-8) + mol_t, mol_1, ins_targets = self._interpolate_train_atomic( + source, + target, + t, + scaffold_pairs=scaffold_pairs, + existing_noise="jitter", + ) + keep_mask = torch.zeros(mol_t.num_nodes, dtype=torch.long) + keep_mask[: len(scaffold_pairs)] = 1 + mol_t.add_node_property("keep_mask", keep_mask) + + if hasattr(target, "y") and target.y is not None: + mol_t.add_system_property("y", target.y) + # Use the resolved molecule index, including when evaluation + # enumerates multiple scaffold cuts of the same ligand. + self._attach_context(base_idx, mol_t) + return self._training_output(mol_t, mol_1, ins_targets, t) + + source, target = evaluation_pair(source, target) + self._attach_context(base_idx, source) + return source, target + + +def evaluation_pair(source, target): + """Complete the source graph and mark every input atom as retained.""" + keep_mask = torch.ones(source.num_nodes, dtype=torch.long) + n_nodes = source.num_nodes + edge_index = build_fully_connected_edge_index(n_nodes) + edge_dtype = source.e.dtype if source.e is not None else torch.long + dense = torch.zeros(n_nodes, n_nodes, dtype=edge_dtype) + if source.edge_index is not None and source.e is not None: + dense[source.edge_index[0], source.edge_index[1]] = source.e + source = rebuild_atomic_with_edges( + source, edge_index, dense[edge_index[0], edge_index[1]] + ) + source.add_node_property("keep_mask", keep_mask) + return source, target diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a62d4083497c96476cf11f482ad769736b7a8e6d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__init__.py @@ -0,0 +1,8 @@ +"""Shared dataset infrastructure used by every dataset and task. + +Holds the native geometric-graph containers (:mod:`atomic_data`, +:mod:`insertion_data`), the token :mod:`vocab`, the :mod:`representation` +projection layer, the flow-matching :mod:`flow_matching_wrapper`, the base +:mod:`lightning_datamodule`, :mod:`preprocessing` and property caching +(:mod:`properties`). +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c3d6eb8a6f6b16a6d0ba65a1e802883ed17fc99 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/atomic_data.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/atomic_data.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba0bfd7a6de2fea9eb3bea19956902595e6366e5 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/atomic_data.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/batch.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/batch.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3852895c99596f7c0f85fbf0ac2d23530ceb334 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/batch.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/containers.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/containers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..15f624f8149ceadba90f6ccd9a7d02db05a01b34 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/containers.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/insertion_data.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/insertion_data.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f327c73c7bd691efa67b02225a2f928aac1a764e Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/insertion_data.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/insertion_targets.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/insertion_targets.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..189de62b250b6da8ccfe35d5f3f1487a8e0eb042 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/insertion_targets.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/preprocessing.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/preprocessing.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d3fa328c1beb27d0826aeb1f3b2b92d705fc7b5d Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/preprocessing.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/representation.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/representation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47e7552393aa53a479a7bb362dbad9b318e2619b Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/representation.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/vocab.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/vocab.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ffbb40b3fbbe9165e0b0900ccaeff887f3145b14 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/__pycache__/vocab.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/atomic_data.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/atomic_data.py new file mode 100644 index 0000000000000000000000000000000000000000..776461c5440a93e54ace90df1713bcdb505886db --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/atomic_data.py @@ -0,0 +1,33 @@ +"""Public Morph geometric-graph data API.""" + +from morph.dataset.core.batch import ( + COFRAMED_CONTEXT_KEYS, + ChemBatch, + batch_from_flat, + batch_remove_com, + batch_translate, + batch_unpack, + chembatch_from_graphs, + cumulative_node_offsets, + filter_nodes, +) +from morph.dataset.core.containers import ( + ChemAtomicData, + make_chem_atomic, + rdkit_to_token_payload, +) + +__all__ = [ + "COFRAMED_CONTEXT_KEYS", + "ChemAtomicData", + "ChemBatch", + "batch_from_flat", + "batch_remove_com", + "batch_translate", + "batch_unpack", + "chembatch_from_graphs", + "cumulative_node_offsets", + "filter_nodes", + "make_chem_atomic", + "rdkit_to_token_payload", +] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/batch.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..74b1069b098f3fc9f52ed0f3efdfe1aaf64cf1ed --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/batch.py @@ -0,0 +1,681 @@ +"""Native flat-tensor batching for Morph geometric graphs.""" + +# The short alias properties and mapping protocol are documented on the class. +# ruff: noqa: D102, D105 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.containers import ChemAtomicData +from morph.utils.graph_math import unsorted_segment_sum + +if TYPE_CHECKING: + from collections.abc import Iterable + + +# Separate geometric context graphs whose coordinates share the molecule frame. +# They are not edited with the molecule, but every global recentering must move +# them by the same per-graph translation. +COFRAMED_CONTEXT_KEYS = ("start_mol", "pocket") + + +def batch_unpack(batch: ChemBatch): + """Return ``(x, a, c, e, edge_index, batch_idx)`` from a batch.""" + return batch.unpack() + + +def batch_remove_com( + batch: ChemBatch, x_mean: torch.Tensor | None = None +) -> torch.Tensor: + """Remove the per-graph coordinate mean, including co-framed context.""" + positions = batch.positions + if x_mean is None: + x_mean = unsorted_segment_sum(positions, batch.batch, batch.num_graphs) + x_mean = x_mean / batch.num_nodes_per_graph.clamp(min=1).unsqueeze(1) + else: + assert x_mean.shape == (batch.num_graphs, positions.shape[-1]), ( + "x_mean must have the same shape as the coordinates" + ) + batch.positions = positions - x_mean[batch.batch] + for key in COFRAMED_CONTEXT_KEYS: + context = getattr(batch, key, None) + if context is not None: + context.positions = context.positions - x_mean[context.batch] + return x_mean + + +def batch_translate(batch: ChemBatch, translation: torch.Tensor) -> None: + """Translate a batch and its geometric context by one vector per graph.""" + if translation.shape != (batch.num_graphs, batch.positions.shape[-1]): + raise ValueError( + "translation must have shape " + f"({batch.num_graphs}, {batch.positions.shape[-1]})" + ) + batch.positions.add_(translation[batch.batch]) + for key in COFRAMED_CONTEXT_KEYS: + context = getattr(batch, key, None) + if context is not None: + context.positions.add_(translation[context.batch]) + + +def _clone_value(value): + return value.clone() if hasattr(value, "clone") else value + + +class ChemBatch: + """A batch of Morph graphs stored as graph-contiguous flat tensors. + + ``data`` holds node-, edge-, and graph-level tensors, while ``keys`` records + their level. Connectivity is natively PyG-shaped ``[2, E]`` and all index + tensors are ``int64``, matching the model and scatter operations directly. + """ + + _ALIASES = { + "x": "positions", + "a": "atom_types", + "c": "charge_types", + "e": "edge_types", + } + _INTERNAL_ATTRS = frozenset( + { + "_data", + "_keys", + "_batch_idx", + "_node_counts", + "_edge_counts", + "_num_graphs", + "_max_seqlen_cache", + } + ) + + def __init__( + self, + *, + data: dict[str, torch.Tensor], + keys: dict[str, set[str]], + batch_idx: torch.Tensor, + node_counts: torch.Tensor, + edge_counts: torch.Tensor, + num_graphs: int, + max_seqlen: int | None = None, + ) -> None: + object.__setattr__(self, "_data", data) + object.__setattr__( + self, "_keys", {level: set(keys.get(level, ())) for level in keys} + ) + for level in ("node", "edge", "system"): + self._keys.setdefault(level, set()) + object.__setattr__(self, "_batch_idx", batch_idx.long()) + object.__setattr__(self, "_node_counts", node_counts.long()) + object.__setattr__(self, "_edge_counts", edge_counts.long()) + object.__setattr__(self, "_num_graphs", int(num_graphs)) + object.__setattr__(self, "_max_seqlen_cache", max_seqlen) + + def __getattr__(self, name: str): + data = self.__dict__.get("_data") + if data is not None and name in data: + return data[name] + raise AttributeError(name) + + def __setattr__(self, name: str, value) -> None: + if name == "edge_index": + self._data["edge_index"] = value.long().contiguous() + elif name == "neighbor_list": + self._data["edge_index"] = value.t().long().contiguous() + elif name == "batch": + object.__setattr__(self, "_batch_idx", value.long()) + elif (key := self._ALIASES.get(name)) is not None: + self._data[key] = value + elif name in self.__dict__.get("_data", {}): + self._data[name] = value + else: + object.__setattr__(self, name, value) + + def __contains__(self, key: str) -> bool: + if key == "neighbor_list": + key = "edge_index" + return key in self._data + + def __getitem__(self, key: str): + if key == "neighbor_list": + return self.neighbor_list + return self._data[key] + + def __setitem__(self, key: str, value) -> None: + if key == "neighbor_list": + self.neighbor_list = value + else: + self._data[key] = value + + def __len__(self) -> int: + return self.num_graphs + + @property + def keys(self) -> dict[str, set[str]]: + return self._keys + + @property + def device(self) -> torch.device: + return self.positions.device + + @property + def x(self) -> torch.Tensor: + return self.positions + + @property + def a(self) -> torch.Tensor: + return self.atom_types + + @property + def c(self) -> torch.Tensor | None: + return self._data.get("charge_types") + + @property + def e(self) -> torch.Tensor | None: + return self._data.get("edge_types") + + @property + def edge_index(self) -> torch.Tensor: + return self._data["edge_index"] + + @property + def neighbor_list(self) -> torch.Tensor: + """Return connectivity in the single-graph ``[E, 2]`` layout.""" + return self.edge_index.t() + + @property + def batch(self) -> torch.Tensor: + return self._batch_idx + + @property + def batch_idx(self) -> torch.Tensor: + return self._batch_idx + + @property + def batch_size(self) -> int: + return self._num_graphs + + @property + def num_graphs(self) -> int: + return self._num_graphs + + @property + def num_nodes(self) -> int: + return self.positions.shape[0] + + @property + def num_edges(self) -> int: + return self.edge_index.shape[1] + + @property + def num_nodes_per_graph(self) -> torch.Tensor: + return self._node_counts + + @property + def num_edges_per_graph(self) -> torch.Tensor: + return self._edge_counts + + @property + def max_num_nodes(self) -> int: + return self.max_seqlen + + @property + def max_seqlen(self) -> int: + cached = self._max_seqlen_cache + if cached is None: + cached = ( + int(self._node_counts.max().item()) + if self._node_counts.numel() > 0 + else 0 + ) + object.__setattr__(self, "_max_seqlen_cache", cached) + return cached + + def unpack(self): + """Return ``(x, a, c, e, edge_index, batch)``.""" + return self.x, self.a, self.c, self.e, self.edge_index, self.batch + + def remove_com(self, x_mean: torch.Tensor | None = None) -> torch.Tensor: + return batch_remove_com(self, x_mean) + + def _extra_attrs(self) -> dict: + return { + key: value + for key, value in self.__dict__.items() + if key not in self._INTERNAL_ATTRS + } + + def clone(self) -> ChemBatch: + """Deep-clone all tensor storage and tensor-like loose attributes.""" + out = type(self)( + data={key: value.clone() for key, value in self._data.items()}, + keys={level: set(names) for level, names in self._keys.items()}, + batch_idx=self._batch_idx.clone(), + node_counts=self._node_counts.clone(), + edge_counts=self._edge_counts.clone(), + num_graphs=self._num_graphs, + max_seqlen=self._max_seqlen_cache, + ) + for key, value in self._extra_attrs().items(): + object.__setattr__(out, key, _clone_value(value)) + return out + + @staticmethod + def _move_tensor( + value: torch.Tensor, + device: torch.device, + dtype: torch.dtype | None, + non_blocking: bool, + ) -> torch.Tensor: + target_dtype = ( + dtype if dtype is not None and value.is_floating_point() else None + ) + return value.to(device=device, dtype=target_dtype, non_blocking=non_blocking) + + def to( + self, + device, + dtype: torch.dtype | None = None, + non_blocking: bool = False, + ) -> ChemBatch: + """Return a moved copy, following ``torch.Tensor.to`` semantics.""" + out = self.clone() + return out.to_inplace(device, dtype=dtype, non_blocking=non_blocking) + + def to_inplace( + self, + device, + dtype: torch.dtype | None = None, + non_blocking: bool = True, + ) -> ChemBatch: + """Move this single-use batch without first copying it.""" + device = torch.device(device) + for key, value in self._data.items(): + self._data[key] = self._move_tensor(value, device, dtype, non_blocking) + object.__setattr__( + self, + "_batch_idx", + self._batch_idx.to(device=device, non_blocking=non_blocking), + ) + object.__setattr__( + self, + "_node_counts", + self._node_counts.to(device=device, non_blocking=non_blocking), + ) + object.__setattr__( + self, + "_edge_counts", + self._edge_counts.to(device=device, non_blocking=non_blocking), + ) + for key, value in self._extra_attrs().items(): + if isinstance(value, ChemBatch): + value.to_inplace(device, dtype=dtype, non_blocking=non_blocking) + elif isinstance(value, torch.Tensor): + object.__setattr__( + self, + key, + self._move_tensor(value, device, dtype, non_blocking), + ) + elif hasattr(value, "to"): + object.__setattr__(self, key, value.to(device)) + return self + + def cpu(self) -> ChemBatch: + """Return a CPU copy.""" + return self.to("cpu") + + def pin_memory(self) -> ChemBatch: + """Pin CPU tensor storage in place for asynchronous CUDA transfer.""" + for key, value in self._data.items(): + self._data[key] = value.pin_memory() + object.__setattr__(self, "_batch_idx", self._batch_idx.pin_memory()) + object.__setattr__(self, "_node_counts", self._node_counts.pin_memory()) + object.__setattr__(self, "_edge_counts", self._edge_counts.pin_memory()) + for key, value in self._extra_attrs().items(): + if isinstance(value, torch.Tensor) or hasattr(value, "pin_memory"): + object.__setattr__(self, key, value.pin_memory()) + return self + + def contiguous(self) -> ChemBatch: + """Make all stored tensors contiguous in place.""" + for key, value in self._data.items(): + self._data[key] = value.contiguous() + object.__setattr__(self, "_batch_idx", self._batch_idx.contiguous()) + return self + + def to_data_list(self) -> list[ChemAtomicData]: + """Split this batch into graph-local ``ChemAtomicData`` objects.""" + node_counts = self._node_counts.tolist() + edge_counts = self._edge_counts.tolist() + node_offsets = [0] + edge_offsets = [0] + for count in node_counts: + node_offsets.append(node_offsets[-1] + count) + for count in edge_counts: + edge_offsets.append(edge_offsets[-1] + count) + + graphs = [] + smiles = getattr(self, "smiles", None) + for graph_idx in range(self.num_graphs): + node_slice = slice(node_offsets[graph_idx], node_offsets[graph_idx + 1]) + edge_slice = slice(edge_offsets[graph_idx], edge_offsets[graph_idx + 1]) + edge_index = self.edge_index[:, edge_slice] - node_offsets[graph_idx] + + extra = {} + for key in self._keys["node"]: + if key not in { + "positions", + "atom_types", + "charge_types", + "is_auxiliary", + }: + extra[key] = self._data[key][node_slice] + for key in self._keys["edge"]: + if key not in {"edge_index", "edge_types"}: + extra[key] = self._data[key][edge_slice] + for key in self._keys["system"]: + extra[key] = self._data[key][graph_idx : graph_idx + 1] + + graph = ChemAtomicData( + positions=self.positions[node_slice], + atom_types=self.atom_types[node_slice], + charge_types=( + None + if "charge_types" not in self + else self.charge_types[node_slice] + ), + edge_types=( + None if "edge_types" not in self else self.edge_types[edge_slice] + ), + neighbor_list=edge_index.t().contiguous(), + is_auxiliary=( + None + if "is_auxiliary" not in self + else self.is_auxiliary[node_slice] + ), + extra=extra, + node_extra_keys=self._keys["node"] + - {"positions", "atom_types", "charge_types", "is_auxiliary"}, + edge_extra_keys=self._keys["edge"] - {"edge_index", "edge_types"}, + system_extra_keys=self._keys["system"], + smiles=( + smiles[graph_idx] if isinstance(smiles, (list, tuple)) else smiles + ), + ) + graphs.append(graph) + return graphs + + @classmethod + def from_data_list(cls, graphs: Iterable[ChemAtomicData]) -> ChemBatch: + """Batch an iterable of Morph graphs.""" + return chembatch_from_graphs(list(graphs), attach_smiles=True) + + +def filter_nodes( + data: ChemAtomicData | ChemBatch, mask: torch.Tensor +) -> ChemAtomicData | ChemBatch: + """Keep selected nodes, drop dangling edges, and preserve graph identities.""" + if isinstance(data, ChemAtomicData): + return data.filter_nodes(mask) + + mask = mask.to(torch.bool).view(-1) + batch_idx = data.batch + keep = mask.nonzero(as_tuple=True)[0] + mapping = torch.full((mask.numel(),), -1, dtype=torch.long, device=data.device) + mapping[keep] = torch.arange(keep.numel(), device=data.device) + + edge_index = data.edge_index + if edge_index.numel() > 0: + remapped = mapping[edge_index] + edge_keep = torch.where((remapped[0] >= 0) & (remapped[1] >= 0))[0] + new_edge_index = remapped[:, edge_keep] + edge_graph = batch_idx[edge_index[0, edge_keep]] + else: + edge_keep = torch.empty(0, dtype=torch.long, device=data.device) + new_edge_index = edge_index + edge_graph = torch.empty(0, dtype=torch.long, device=data.device) + + # Resolve each variable-length selection once. Repeating boolean indexing + # for every feature would synchronize the CPU with CUDA each time. + new_batch_idx = batch_idx[keep] + node_counts = torch.bincount(new_batch_idx, minlength=data.num_graphs) + edge_counts = torch.bincount(edge_graph, minlength=data.num_graphs) + node_data = {key: data[key][keep] for key in data.keys["node"]} + edge_data = { + key: data[key][edge_keep] for key in data.keys["edge"] if key != "edge_index" + } + system_data = {key: data[key] for key in data.keys["system"]} + return _chem_batch_from_level_data( + node_data=node_data, + edge_data=edge_data, + system_data=system_data, + edge_index=new_edge_index, + node_counts=node_counts, + edge_counts=edge_counts, + batch_idx=new_batch_idx, + num_graphs=data.num_graphs, + ) + + +def cumulative_node_offsets(data_list: list[ChemAtomicData]) -> list[int]: + """Return the exclusive prefix sum of per-graph node counts.""" + offsets = [] + total = 0 + for data in data_list: + offsets.append(total) + total += data.num_nodes + return offsets + + +def batch_from_flat( + positions: torch.Tensor, + atom_types: torch.Tensor, + charge_types: torch.Tensor | None, + edge_types: torch.Tensor | None, + edge_index: torch.Tensor | None, + batch_idx: torch.Tensor, + *, + num_graphs: int | None = None, + node_extras: dict[str, torch.Tensor] | None = None, + edge_extras: dict[str, torch.Tensor] | None = None, + system_extras: dict[str, torch.Tensor] | None = None, + _already_sorted: bool = False, + _node_counts: torch.Tensor | None = None, + _edge_counts: torch.Tensor | None = None, +) -> ChemBatch: + """Build a graph-contiguous batch from flat tensors. + + Generic inputs are stably grouped by graph. Internal collates can set + ``_already_sorted`` because they construct graph-contiguous tensors directly, + avoiding redundant sorting in the dataloader path. + """ + batch_idx = batch_idx.long() + if num_graphs is None: + num_graphs = int(batch_idx.max().item()) + 1 if batch_idx.numel() else 0 + node_extras = node_extras or {} + edge_extras = edge_extras or {} + system_extras = system_extras or {} + device = positions.device + + if _already_sorted: + sorted_batch = batch_idx + node_data = {"positions": positions, "atom_types": atom_types} + if charge_types is not None: + node_data["charge_types"] = charge_types + node_data.update(node_extras) + else: + order = torch.argsort(batch_idx, stable=True) + inverse = torch.empty_like(order) + inverse[order] = torch.arange(order.numel(), device=device) + sorted_batch = batch_idx[order] + node_data = { + "positions": positions[order], + "atom_types": atom_types[order], + } + if charge_types is not None: + node_data["charge_types"] = charge_types[order] + node_data.update({key: value[order] for key, value in node_extras.items()}) + node_counts = ( + _node_counts + if _node_counts is not None + else torch.bincount(sorted_batch, minlength=num_graphs) + ) + + if edge_index is None: + edge_index = torch.empty((2, 0), dtype=torch.long, device=device) + else: + edge_index = edge_index.long() + edge_data = {} + edge_counts = ( + _edge_counts + if _edge_counts is not None + else torch.zeros(num_graphs, dtype=torch.long, device=device) + ) + if edge_index.numel() > 0: + if not _already_sorted or _edge_counts is None: + edge_graph = batch_idx[edge_index[0]] + if _already_sorted: + if edge_types is not None: + edge_data["edge_types"] = edge_types + edge_data.update(edge_extras) + else: + edge_order = torch.argsort(edge_graph, stable=True) + edge_index = inverse[edge_index][:, edge_order].contiguous() + if edge_types is not None: + edge_data["edge_types"] = edge_types[edge_order] + edge_data.update( + {key: value[edge_order] for key, value in edge_extras.items()} + ) + if _edge_counts is None: + edge_counts = torch.bincount(edge_graph, minlength=num_graphs) + elif edge_types is not None: + edge_data["edge_types"] = edge_types + return _chem_batch_from_level_data( + node_data=node_data, + edge_data=edge_data, + system_data=dict(system_extras), + edge_index=edge_index, + node_counts=node_counts, + edge_counts=edge_counts, + batch_idx=sorted_batch, + num_graphs=num_graphs, + ) + + +def _common_extra_keys(graphs: list[ChemAtomicData], attr: str) -> set[str]: + if not graphs: + return set() + common = set(getattr(graphs[0], attr)) + for graph in graphs[1:]: + common.intersection_update(getattr(graph, attr)) + return common + + +def chembatch_from_graphs( + graphs: list[ChemAtomicData], + *, + node_extra_keys: tuple = (), + edge_extra_keys: tuple = (), + system_extra_keys: tuple = (), + attach_smiles: bool = False, +) -> ChemBatch: + """Batch Morph graphs and route their registered optional tensors.""" + graphs = list(graphs) + if not graphs: + raise ValueError("cannot batch an empty graph list") + node_keys = set(node_extra_keys) | _common_extra_keys(graphs, "node_extra_keys") + edge_keys = set(edge_extra_keys) | _common_extra_keys(graphs, "edge_extra_keys") + system_keys = set(system_extra_keys) | _common_extra_keys( + graphs, "system_extra_keys" + ) + + def _gather(keys: set[str]) -> dict[str, torch.Tensor]: + gathered = {} + for key in keys: + values = [graph.extra.get(key) for graph in graphs] + if all(isinstance(value, torch.Tensor) for value in values): + gathered[key] = torch.cat(values) + return gathered + + positions = torch.cat([graph.positions for graph in graphs]) + atom_types = torch.cat([graph.atom_types for graph in graphs]) + charge_types = ( + torch.cat([graph.charge_types for graph in graphs]) + if all(graph.charge_types is not None for graph in graphs) + else None + ) + edge_types = ( + torch.cat([graph.edge_types for graph in graphs]) + if all(graph.edge_types is not None for graph in graphs) + else None + ) + node_extras = _gather(node_keys) + if all(graph.is_auxiliary is not None for graph in graphs): + node_extras["is_auxiliary"] = torch.cat( + [graph.is_auxiliary for graph in graphs] + ) + + device = positions.device + edge_parts = [] + batch_parts = [] + offset = 0 + for graph_idx, graph in enumerate(graphs): + if graph.edge_index is not None and graph.edge_index.numel() > 0: + edge_parts.append(graph.edge_index + offset) + batch_parts.append( + torch.full((graph.num_nodes,), graph_idx, dtype=torch.long, device=device) + ) + offset += graph.num_nodes + edge_index = ( + torch.cat(edge_parts, dim=1) + if edge_parts + else torch.empty((2, 0), dtype=torch.long, device=device) + ) + batch = batch_from_flat( + positions, + atom_types, + charge_types, + edge_types, + edge_index, + torch.cat(batch_parts), + num_graphs=len(graphs), + node_extras=node_extras, + edge_extras=_gather(edge_keys), + system_extras=_gather(system_keys), + _already_sorted=True, + ) + object.__setattr__(batch, "_max_seqlen_cache", max(g.num_nodes for g in graphs)) + if attach_smiles: + smiles = [graph.smiles for graph in graphs] + if any(value is not None for value in smiles): + batch.smiles = smiles + return batch + + +def _chem_batch_from_level_data( + *, + node_data: dict[str, torch.Tensor], + edge_data: dict[str, torch.Tensor], + system_data: dict[str, torch.Tensor], + edge_index: torch.Tensor, + node_counts: torch.Tensor, + edge_counts: torch.Tensor, + batch_idx: torch.Tensor, + num_graphs: int, +) -> ChemBatch: + data = {**node_data, **edge_data, **system_data, "edge_index": edge_index} + return ChemBatch( + data=data, + keys={ + "node": set(node_data), + "edge": set(edge_data) | {"edge_index"}, + "system": set(system_data), + }, + batch_idx=batch_idx, + node_counts=node_counts, + edge_counts=edge_counts, + num_graphs=num_graphs, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/containers.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/containers.py new file mode 100644 index 0000000000000000000000000000000000000000..a333168fd99072070f3f38e9d62ac0347cb73d42 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/containers.py @@ -0,0 +1,525 @@ +"""Native per-graph container and RDKit conversion helpers. + +``ChemAtomicData`` is Morph's single graph representation. It deliberately +contains only the tensors used by the generative model and lightweight metadata +that tells the batcher whether an optional tensor belongs to nodes, edges, or +the whole graph. Construction and topology edits do not invoke schema +validation; dataset adapters are responsible for validating external input. +""" + +# The short alias properties and mapping protocol are documented on the class. +# ruff: noqa: D102, D105 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import torch +from rdkit import Chem +from torch_geometric.utils import to_dense_adj + +from morph.utils.rdkit_utils import BOND_IDX_MAP, IDX_BOND_MAP, tensors_to_rdkit_mol +from morph.utils.vocab_utils import index_to_token + +if TYPE_CHECKING: + from collections.abc import Iterable + + from morph.dataset.core.vocab import Vocab + + +def rdkit_to_token_payload(mol, vocab: Vocab, sanitize: bool = False) -> dict: + """Convert an RDKit molecule to Morph's token-index graph tensors.""" + if sanitize: + mol = Chem.Mol(mol) + Chem.SanitizeMol(mol) + if mol.GetNumConformers() == 0: + raise ValueError("from_rdkit requires a mol with a 3D conformer") + + atoms = list(mol.GetAtoms()) + atom_types = torch.tensor( + [vocab.atom_tokens.index(a.GetSymbol()) for a in atoms], dtype=torch.long + ) + charge_types = torch.tensor( + [vocab.charge_tokens.index(str(a.GetFormalCharge())) for a in atoms], + dtype=torch.long, + ) + positions = torch.tensor(mol.GetConformer().GetPositions(), dtype=torch.float32) + + rows, cols, edge_types = [], [], [] + for bond in mol.GetBonds(): + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + edge_type = vocab.edge_tokens.index(str(BOND_IDX_MAP[bond.GetBondType()])) + rows += [i, j] + cols += [j, i] + edge_types += [edge_type, edge_type] + return { + "positions": positions, + "atom_types": atom_types, + "charge_types": charge_types, + "edge_types": torch.tensor(edge_types, dtype=torch.long), + "neighbor_list": torch.tensor([rows, cols], dtype=torch.long).t().contiguous(), + } + + +def _remap_neighbor_list( + mask: torch.Tensor, + neighbor_list: torch.Tensor | None, + num_nodes: int, + node_index_map: torch.Tensor | None = None, +) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]: + """Compact node indices under a keep mask and drop dangling edges.""" + if neighbor_list is None or neighbor_list.numel() == 0: + return neighbor_list, None, node_index_map + device = neighbor_list.device + if node_index_map is None: + keep = mask.nonzero(as_tuple=True)[0] + node_index_map = torch.full((num_nodes,), -1, dtype=torch.long, device=device) + node_index_map[keep] = torch.arange(keep.numel(), device=device) + remapped = node_index_map[neighbor_list] + edge_keep = (remapped[:, 0] >= 0) & (remapped[:, 1] >= 0) + return remapped[edge_keep], edge_keep, node_index_map + + +# Dataset/conditioning fields known by the shared collates. A graph can register +# arbitrary additional fields through add_{node,edge,system}_property as well. +_NODE_CONDITION_KEYS = ("keep_mask", "fragment_mask", "linker_mask", "anchors") +_SYSTEM_CONDITION_KEYS = ("y", "qed", "logp") +_STRING_CONDITION_KEYS = ("smiles",) +_CONDITION_KEYS = ( + *_NODE_CONDITION_KEYS, + *_SYSTEM_CONDITION_KEYS, + *_STRING_CONDITION_KEYS, +) + + +class ChemAtomicData: + """A token-index geometric graph used throughout Morph. + + Core tensors use descriptive storage names and expose the established Morph + aliases ``x``/``a``/``c``/``e``/``edge_index``. Connectivity is stored as an + ``[E, 2]`` neighbor list on single graphs because topology-edit operations + index both endpoints together. Optional tensors live in ``extra`` and are + explicitly registered at node, edge, or graph level for batching. + """ + + __slots__ = ( + "_edge_extra_keys", + "_node_extra_keys", + "_system_extra_keys", + "atom_types", + "charge_types", + "edge_types", + "extra", + "is_auxiliary", + "neighbor_list", + "positions", + "smiles", + ) + + _CORE_SLOTS = frozenset(__slots__) + _NODE_CORE_FIELDS = ("positions", "atom_types", "charge_types", "is_auxiliary") + + def __init__( + self, + positions: torch.Tensor, + atom_types: torch.Tensor, + charge_types: torch.Tensor | None = None, + edge_types: torch.Tensor | None = None, + neighbor_list: torch.Tensor | None = None, + is_auxiliary: torch.Tensor | None = None, + *, + extra: dict | None = None, + node_extra_keys: Iterable[str] = (), + edge_extra_keys: Iterable[str] = (), + system_extra_keys: Iterable[str] = (), + smiles: str | None = None, + ) -> None: + object.__setattr__(self, "positions", positions) + object.__setattr__(self, "atom_types", atom_types) + object.__setattr__(self, "charge_types", charge_types) + object.__setattr__(self, "edge_types", edge_types) + object.__setattr__(self, "neighbor_list", neighbor_list) + object.__setattr__(self, "is_auxiliary", is_auxiliary) + object.__setattr__(self, "extra", dict(extra) if extra is not None else {}) + object.__setattr__(self, "smiles", smiles) + object.__setattr__(self, "_node_extra_keys", set(node_extra_keys)) + object.__setattr__(self, "_edge_extra_keys", set(edge_extra_keys)) + object.__setattr__(self, "_system_extra_keys", set(system_extra_keys)) + + def __setattr__(self, name: str, value) -> None: + aliases = { + "x": "positions", + "a": "atom_types", + "c": "charge_types", + "e": "edge_types", + } + if name == "edge_index": + object.__setattr__( + self, + "neighbor_list", + None if value is None else value.t().contiguous().long(), + ) + elif (slot := aliases.get(name)) is not None: + object.__setattr__(self, slot, value) + elif name in self._CORE_SLOTS: + object.__setattr__(self, name, value) + else: + self.extra[name] = value + + def __getattr__(self, name: str): + if name.startswith("__") or name == "extra": + raise AttributeError(name) + try: + return self.extra[name] + except (KeyError, AttributeError): + raise AttributeError(name) from None + + def __delattr__(self, name: str) -> None: + if name in self._CORE_SLOTS: + object.__delattr__(self, name) + return + try: + del self.extra[name] + except KeyError: + raise AttributeError(name) from None + self._node_extra_keys.discard(name) + self._edge_extra_keys.discard(name) + self._system_extra_keys.discard(name) + + def __contains__(self, key: str) -> bool: + if key in self._CORE_SLOTS: + return getattr(self, key, None) is not None + return key in self.extra + + def __getitem__(self, key: str): + return getattr(self, key) + + @property + def num_nodes(self) -> int: + return self.positions.shape[0] + + @property + def num_edges(self) -> int: + return 0 if self.neighbor_list is None else self.neighbor_list.shape[0] + + @property + def device(self) -> torch.device: + return self.positions.device + + @property + def x(self) -> torch.Tensor: + return self.positions + + @property + def a(self) -> torch.Tensor: + return self.atom_types + + @property + def c(self) -> torch.Tensor | None: + return self.charge_types + + @property + def e(self) -> torch.Tensor | None: + return self.edge_types + + @property + def edge_index(self) -> torch.Tensor | None: + return None if self.neighbor_list is None else self.neighbor_list.t() + + @property + def node_extra_keys(self) -> frozenset[str]: + return frozenset(self._node_extra_keys) + + @property + def edge_extra_keys(self) -> frozenset[str]: + return frozenset(self._edge_extra_keys) + + @property + def system_extra_keys(self) -> frozenset[str]: + return frozenset(self._system_extra_keys) + + def add_node_property(self, key: str, value, node_dim: int = 0) -> None: + del node_dim # Node tensors are consistently node-major in Morph. + self.extra[key] = value + self._node_extra_keys.add(key) + self._edge_extra_keys.discard(key) + self._system_extra_keys.discard(key) + + def add_edge_property(self, key: str, value) -> None: + self.extra[key] = value + self._edge_extra_keys.add(key) + self._node_extra_keys.discard(key) + self._system_extra_keys.discard(key) + + def add_system_property(self, key: str, value) -> None: + self.extra[key] = value + self._system_extra_keys.add(key) + self._node_extra_keys.discard(key) + self._edge_extra_keys.discard(key) + + def unpack(self): + """Return ``(x, a, c, e, edge_index)``.""" + return self.x, self.a, self.c, self.e, self.edge_index + + def get_edge_types_triu(self, no_bond_idx: int = 0): + """Return upper-triangular edge types and their ``[2, E]`` indices.""" + dense = to_dense_adj( + self.edge_index, edge_attr=self.edge_types, max_num_nodes=self.num_nodes + ).squeeze() + if no_bond_idx != 0: + present = ( + to_dense_adj(self.edge_index, max_num_nodes=self.num_nodes) + .squeeze() + .bool() + ) + dense = dense.masked_fill(~present, no_bond_idx) + edge_index_triu = torch.triu_indices( + self.num_nodes, self.num_nodes, offset=1, device=self.device + ) + return dense[edge_index_triu[0], edge_index_triu[1]], edge_index_triu + + def to_rdkit_mol( + self, + atom_tokens: list[str], + edge_tokens: list[str], + charge_tokens: list[str], + sanitize: bool = True, + *, + implicit_hydrogens: bool | None = None, + ): + """Convert to RDKit; H-free vocabularies default to implicit hydrogens. + + This uses the vocabulary, never the atoms in an individual sample: a + missing H in an explicit-H model must still count as a chemistry error. + """ + if self.num_nodes == 0: + return None + + # Hydra can supply ListConfig vocabularies. Resolve each small table + # once instead of repeating config lookups for every atom and pair. + atom_tokens = tuple(atom_tokens) + edge_tokens = tuple(edge_tokens) + charge_tokens = tuple(charge_tokens) + + atom_indices = self.atom_types.detach().cpu().numpy() + positions = self.positions.detach().cpu().numpy() + charge_indices = self.charge_types.detach().cpu().numpy() + atom_names = [index_to_token(atom_tokens, int(index)) for index in atom_indices] + charges = [ + int(index_to_token(charge_tokens, int(index))) for index in charge_indices + ] + + bond_types = [] + bond_indices = [] + if self.num_nodes > 1: + edge_types_triu, edge_index_triu = self.get_edge_types_triu( + no_bond_idx=edge_tokens.index("") + ) + indices = ( + edge_types_triu.detach().cpu().numpy().astype(np.int64, copy=False) + ) + # Convert only chemical bonds to Python objects; dense graphs mostly + # contain pairs. Preserve upper-triangle insertion order. + is_bond = np.array([token != "" for token in edge_tokens])[indices] + bond_indices = edge_index_triu.detach().cpu().numpy()[:, is_bond].T.tolist() + bond_types = [ + IDX_BOND_MAP[int(edge_tokens[index])] for index in indices[is_bond] + ] + + return tensors_to_rdkit_mol( + atom_names, + positions, + charges, + bond_types, + bond_indices, + sanitize=sanitize, + implicit_hydrogens=( + "H" not in atom_tokens + if implicit_hydrogens is None + else implicit_hydrogens + ), + ) + + def to_rdkit(self, vocab: Vocab, sanitize: bool = True): + """Convert this graph to an RDKit molecule using ``vocab``.""" + return self.to_rdkit_mol( + vocab.atom_tokens, + vocab.edge_tokens, + vocab.charge_tokens, + sanitize=sanitize, + ) + + @classmethod + def from_rdkit(cls, mol, vocab: Vocab, sanitize: bool = False) -> ChemAtomicData: + """Build a graph from an RDKit molecule.""" + return cls(**rdkit_to_token_payload(mol, vocab, sanitize)) + + @classmethod + def from_atomic(cls, data: ChemAtomicData, clone: bool = True) -> ChemAtomicData: + """Copy ``data`` and ensure it carries an auxiliary-node mask.""" + + def _copy(value): + return value.clone() if clone and isinstance(value, torch.Tensor) else value + + is_auxiliary = data.is_auxiliary + if is_auxiliary is None: + is_auxiliary = torch.zeros( + (data.num_nodes, 1), dtype=torch.bool, device=data.device + ) + else: + is_auxiliary = _copy(is_auxiliary) + return cls( + positions=_copy(data.positions), + atom_types=_copy(data.atom_types), + charge_types=_copy(data.charge_types), + edge_types=_copy(data.edge_types), + neighbor_list=_copy(data.neighbor_list), + is_auxiliary=is_auxiliary, + extra={key: _copy(value) for key, value in data.extra.items()}, + node_extra_keys=data.node_extra_keys, + edge_extra_keys=data.edge_extra_keys, + system_extra_keys=data.system_extra_keys, + smiles=data.smiles, + ) + + def filter_nodes( + self, mask: torch.Tensor, node_index_map: torch.Tensor | None = None + ) -> ChemAtomicData: + """Return a graph containing only nodes selected by ``mask``.""" + mask = mask.to(torch.bool).view(-1) + neighbor_list, edge_keep, _ = _remap_neighbor_list( + mask, self.neighbor_list, self.num_nodes, node_index_map + ) + + extra = dict(self.extra) + for key in self._node_extra_keys: + value = extra.get(key) + if isinstance(value, torch.Tensor): + extra[key] = value[mask] + if edge_keep is not None: + for key in self._edge_extra_keys: + value = extra.get(key) + if isinstance(value, torch.Tensor): + extra[key] = value[edge_keep] + + return type(self)( + positions=self.positions[mask], + atom_types=self.atom_types[mask], + charge_types=( + None if self.charge_types is None else self.charge_types[mask] + ), + edge_types=( + self.edge_types + if self.edge_types is None or edge_keep is None + else self.edge_types[edge_keep] + ), + neighbor_list=neighbor_list, + is_auxiliary=( + None if self.is_auxiliary is None else self.is_auxiliary[mask] + ), + extra=extra, + node_extra_keys=self._node_extra_keys, + edge_extra_keys=self._edge_extra_keys, + system_extra_keys=self._system_extra_keys, + smiles=self.smiles, + ) + + def pad(self, num_auxiliary: int) -> ChemAtomicData: + """Append zero-initialized auxiliary nodes in place.""" + if num_auxiliary <= 0: + return self + old_num_nodes = self.num_nodes + if self.is_auxiliary is None: + self.is_auxiliary = torch.zeros( + (old_num_nodes, 1), dtype=torch.bool, device=self.device + ) + for name in self._NODE_CORE_FIELDS: + value = getattr(self, name) + if isinstance(value, torch.Tensor): + padding = torch.zeros( + (num_auxiliary, *value.shape[1:]), + dtype=value.dtype, + device=value.device, + ) + object.__setattr__(self, name, torch.cat([value, padding])) + for key in self._node_extra_keys: + value = self.extra.get(key) + if isinstance(value, torch.Tensor): + padding = torch.zeros( + (num_auxiliary, *value.shape[1:]), + dtype=value.dtype, + device=value.device, + ) + self.extra[key] = torch.cat([value, padding]) + self.is_auxiliary[old_num_nodes:] = True + return self + + def permute_nodes(self, indices: torch.Tensor | list[int]) -> ChemAtomicData: + """Reorder nodes and remap connectivity in place.""" + perm = torch.as_tensor(indices, device=self.device, dtype=torch.long) + if self.neighbor_list is not None and self.neighbor_list.numel() > 0: + mapping = torch.full( + (self.num_nodes,), -1, dtype=torch.long, device=self.device + ) + mapping[perm] = torch.arange(perm.numel(), device=self.device) + self.neighbor_list = mapping[self.neighbor_list] + for name in self._NODE_CORE_FIELDS: + value = getattr(self, name) + if isinstance(value, torch.Tensor): + object.__setattr__(self, name, value[perm]) + for key in self._node_extra_keys: + value = self.extra.get(key) + if isinstance(value, torch.Tensor): + self.extra[key] = value[perm] + return self + + def clone(self) -> ChemAtomicData: + """Deep-clone tensor fields while preserving non-tensor metadata.""" + + def _copy(value): + return value.clone() if isinstance(value, torch.Tensor) else value + + return type(self)( + positions=_copy(self.positions), + atom_types=_copy(self.atom_types), + charge_types=_copy(self.charge_types), + edge_types=_copy(self.edge_types), + neighbor_list=_copy(self.neighbor_list), + is_auxiliary=_copy(self.is_auxiliary), + extra={key: _copy(value) for key, value in self.extra.items()}, + node_extra_keys=self._node_extra_keys, + edge_extra_keys=self._edge_extra_keys, + system_extra_keys=self._system_extra_keys, + smiles=self.smiles, + ) + + +def make_chem_atomic( + x: torch.Tensor, + a: torch.Tensor, + c: torch.Tensor | None = None, + e: torch.Tensor | None = None, + edge_index: torch.Tensor | None = None, + **conditions, +) -> ChemAtomicData: + """Build a graph from Morph's canonical short-form tensor arguments.""" + graph = ChemAtomicData( + positions=x, + atom_types=a.long(), + charge_types=None if c is None else c.long(), + edge_types=None if e is None else e.long(), + neighbor_list=( + None if edge_index is None else edge_index.t().contiguous().long() + ), + ) + for key, value in conditions.items(): + if value is None: + continue + if key in _NODE_CONDITION_KEYS: + graph.add_node_property(key, value) + elif key in _SYSTEM_CONDITION_KEYS: + graph.add_system_property(key, value) + else: + setattr(graph, key, value) + return graph diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/conversion.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/conversion.py new file mode 100644 index 0000000000000000000000000000000000000000..1b315bee33812bd1f7c5833ff9869a1505612274 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/conversion.py @@ -0,0 +1,53 @@ +"""Convert sanitized RDKit conformers to the shared molecular graph fields.""" + +import torch +from rdkit import Chem +from torch_geometric.data import Data + +from morph.utils.rdkit_utils import BOND_IDX_MAP, mol_is_valid, smiles_from_mol + + +def mol_to_data( + mol: Chem.Mol, + *, + hydrogen_mode: str = "explicit", + max_atoms: int | None = None, + allowed_atom_symbols: tuple[str, ...] | None = None, +) -> Data | None: + """Filter a sanitized conformer and encode its explicit or heavy-atom graph. + + Filters use the source atom count. RDKit removes H before extracting fields, + preserving heavy-atom order, coordinates, charges, and stereochemistry. + """ + if hydrogen_mode not in ("explicit", "implicit"): + raise ValueError("hydrogen_mode must be explicit or implicit") + if not mol_is_valid(mol, allow_charged=True): + return None + if max_atoms is not None and mol.GetNumAtoms() > max_atoms: + return None + if allowed_atom_symbols is not None and any( + atom.GetSymbol() not in allowed_atom_symbols for atom in mol.GetAtoms() + ): + return None + if hydrogen_mode == "implicit": + if all(atom.GetAtomicNum() == 1 for atom in mol.GetAtoms()): + raise ValueError("Cannot represent a molecule with no heavy atoms") + mol = Chem.RemoveAllHs(mol) + Chem.Kekulize(mol, clearAromaticFlags=True) + pairs, labels = [], [] + for bond in mol.GetBonds(): + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + pairs.extend(((i, j), (j, i))) + labels.extend([BOND_IDX_MAP[bond.GetBondType()]] * 2) + edges = torch.tensor(pairs, dtype=torch.long).reshape(-1, 2).t() + order = (edges[0] * mol.GetNumAtoms() + edges[1]).argsort() + data = Data( + z=torch.tensor([atom.GetAtomicNum() for atom in mol.GetAtoms()]), + pos=torch.tensor(mol.GetConformer().GetPositions(), dtype=torch.float), + charges=torch.tensor([atom.GetFormalCharge() for atom in mol.GetAtoms()]), + edge_index=edges[:, order].contiguous(), + edge_attr=torch.tensor(labels, dtype=torch.long)[order], + smiles=smiles_from_mol(mol, canonical=True) or "", + ) + + return data if torch.isfinite(data.pos).all() else None diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/flow_matching_wrapper.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/flow_matching_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..d5932d822363411da8ef920648195ed8ae68ae72 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/flow_matching_wrapper.py @@ -0,0 +1,224 @@ +import numpy as np +import torch +from scipy.spatial.transform import Rotation +from torch.utils.data import Dataset + +from morph.dataset.core.atomic_data import chembatch_from_graphs +from morph.dataset.core.insertion_targets import collate_insertion_targets +from morph.dataset.core.representation import ( + Representation, + project_molecule_to_representation, + validate_representation, +) +from morph.dataset.core.vocab import Vocab +from morph.flow_matching.prior import sample_prior_graph + + +def uniform_n_atoms_range(distributions, padding: int = 5) -> tuple[int, int]: + """Return the uniform source-count bounds around the empirical support.""" + padding = int(padding) + if padding < 0: + raise ValueError(f"uniform n-atoms padding must be non-negative, got {padding}") + support = (distributions.n_atoms_distribution > 0).nonzero(as_tuple=True)[0] + return ( + max(int(support[0].item()) - padding, 3), + int(support[-1].item()) + padding, + ) + + +class FlowMatchingDatasetWrapper(Dataset): + """Create interpolated training items inside DataLoader workers. + + Training returns ``(mol_t, mol_1, insertion_targets, t)``; validation and + test return a sampled prior and target pair. Multiple augmentations share + the same interpolation and differ only by rotation. + """ + + coframed_context_keys = () + + def __init__( + self, + base_dataset, + distributions, + interpolator, + vocab: Vocab, + representation: str | Representation = Representation.MOLECULE, + n_atoms_strategy="uniform", + uniform_n_atoms_padding: int = 5, + time_dist=None, + stage="train", + rotate: bool = False, + n_augmentations: int = 1, + size_sampler=None, + ): + self.representation = Representation(representation) + caps = getattr(type(base_dataset), "CAPABILITIES", None) + if caps is None: + raise ValueError( + f"Dataset {type(base_dataset).__name__} must declare a " + f"CAPABILITIES attribute to be used with a representation." + ) + validate_representation(caps, self.representation) + + self.base_dataset = base_dataset + self.distributions = distributions + self.interpolator = interpolator + self.vocab = vocab + self.n_atoms_strategy = n_atoms_strategy + self.uniform_n_atoms_padding = int(uniform_n_atoms_padding) + # An optional learned sampler can travel with a checkpoint and be attached + # post-construction instead of re-deriving the starting-size rule here. + self.size_sampler = size_sampler + self.time_dist = time_dist + self.stage = stage + self.rotate = rotate + self.n_augmentations = max(1, int(n_augmentations)) + + if self.n_atoms_strategy == "uniform": + self._n_atoms_min, self._n_atoms_max = uniform_n_atoms_range( + self.distributions, self.uniform_n_atoms_padding + ) + + def _get_n_atoms(self, target): + if self.n_atoms_strategy == "approx": + n = target.num_nodes + int((torch.randn(1) * 2).round().item()) + return max(3, n) + if self.n_atoms_strategy == "uniform": + return int( + torch.randint(self._n_atoms_min, self._n_atoms_max + 1, (1,)).item() + ) + if self.n_atoms_strategy == "learned": + if self.size_sampler is None: + raise ValueError( + 'n_atoms_strategy="learned" requires a trained `size_sampler`' + ) + return int(self.size_sampler.sample(1).item()) + if self.n_atoms_strategy == "empirical": + return None + raise ValueError( + f"unknown n_atoms_strategy {self.n_atoms_strategy!r}; expected one " + "of 'uniform', 'approx', 'empirical', 'learned'" + ) + + def _rotate(self, *molecules): + """Apply one shared random rotation to a group of molecular states.""" + if self.rotate: + R = torch.from_numpy(Rotation.random(1).as_matrix()[0]).to( + dtype=molecules[0].positions.dtype + ) + for mol in molecules: + mol.positions = mol.positions @ R + for key in self.coframed_context_keys: + context = getattr(mol, key, None) + if context is not None: + context.positions = context.positions @ R + + def _augment(self, mol_t, mol_1, ins_targets, t): + """Return one copy with a shared random rotation across all targets.""" + augmented = tuple(mol.clone() for mol in (mol_t, mol_1, ins_targets)) + for original, clone in zip((mol_t, mol_1, ins_targets), augmented, strict=True): + for key in self.coframed_context_keys: + context = getattr(original, key, None) + if context is not None: + setattr(clone, key, context.clone()) + self._rotate(*augmented) + return *augmented, t + + def _training_output(self, mol_t, mol_1, ins_targets, t): + """Apply configured rotations and return one or several training items.""" + if self.n_augmentations == 1: + self._rotate(mol_t, mol_1, ins_targets) + return mol_t, mol_1, ins_targets, t + return [ + self._augment(mol_t, mol_1, ins_targets, t) + for _ in range(self.n_augmentations) + ] + + def _interpolate_train_atomic( + self, + source_atomic, + target, + t, + scaffold_pairs=None, + existing_noise="move", + ): + """Interpolate a training pair into native Morph graph objects. + + Insertion targets retain their structured bond records. The collator + offsets their current-node and inserted-node indices independently. + """ + interp, target_state, ins = self.interpolator.interpolate_single( + source_atomic, + target, + t.item(), + scaffold_pairs=scaffold_pairs, + existing_noise=existing_noise, + ) + return interp, target_state, ins + + def __getitem__(self, index): + """Build one interpolated source/target training example.""" + target = self.base_dataset[index] + target = project_molecule_to_representation( + target, self.vocab, self.representation + ) + + n_atoms = self._get_n_atoms(target) + + if self.stage == "train": + t = self.time_dist.sample((1,)).squeeze(0) + t = torch.clamp(t, min=0.0, max=1 - 1e-8) + + mol_t, mol_1, ins_targets = self._interpolate_train_atomic( + sample_prior_graph(self.distributions, n_atoms=n_atoms), target, t + ) + + if hasattr(target, "y") and target.y is not None: + mol_t.add_system_property("y", target.y) + + self._attach_context(index, mol_t) + + return self._training_output(mol_t, mol_1, ins_targets, t) + + sample = sample_prior_graph(self.distributions, n_atoms=n_atoms) + self._attach_context(index, sample) + return sample, target + + def _attach_context(self, index, mol_t): + """Task hook for fixed geometric context, attached before augmentation.""" + + def __len__(self): + """Return the number of examples in the wrapped dataset.""" + return len(self.base_dataset) + + +def train_collate_fn(batch): + """Batch molecular states and structured insertion supervision.""" + samples = [ + sample + for item in batch + for sample in (item if isinstance(item, list) else [item]) + ] + mols_t, mols_1, insertions, times = zip(*samples, strict=True) + return ( + chembatch_from_graphs(list(mols_t)), + chembatch_from_graphs(list(mols_1)), + collate_insertion_targets(list(insertions), [mol.num_nodes for mol in mols_t]), + torch.stack(times), + ) + + +def eval_collate_fn(batch): + """Collate prior/target pairs into native ``ChemBatch`` objects.""" + samples, targets = zip(*batch, strict=True) + return ( + chembatch_from_graphs(list(samples)), + chembatch_from_graphs(list(targets)), + ) + + +def worker_init_fn(_worker_id): + """Seed numpy per worker so scipy/numpy randomness diverges across workers.""" + info = torch.utils.data.get_worker_info() + seed = info.seed % (2**32) + np.random.seed(seed) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/insertion_data.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/insertion_data.py new file mode 100644 index 0000000000000000000000000000000000000000..d8415a4e5bd055330292aab3a0f59dec913cc372 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/insertion_data.py @@ -0,0 +1,350 @@ +"""Atom insertion / deletion editing ops for molecule batches. + +Split out of :mod:`morph.dataset.core.atomic_data`: the point-cloud +insertion primitives -- bundling freshly inserted atoms +(:class:`InsertedAtoms`), pairing them within a graph, sampling new edge +types, and joining them into an existing :class:`ChemBatch` +with either sampled (:func:`join_with_atoms`) or model-predicted +(:func:`join_with_predicted_edges`) bonds. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.atomic_data import ChemBatch, batch_from_flat +from morph.utils.edge_alignment import EDGE_ALIGNER + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass +class InsertedAtoms: + """Flat features of freshly inserted atoms (the point-cloud analogue). + + Bundles the position / atom-type / charge-type tensors that were otherwise + threaded around as loose ``ins_x`` / ``ins_a`` / ``ins_c`` triplets, with an + optional per-atom ``batch`` assignment. Indexing gathers every field at once, + so ``atoms[idx]`` replaces three (or more) parallel gathers and is how callers + select an edge-aligned subset to feed the insertion-edge head. + """ + + x: torch.Tensor # [N, 3] positions + a: torch.Tensor # [N] atom-type tokens + c: torch.Tensor # [N] charge tokens + batch: torch.Tensor | None = None # [N] graph id per inserted atom + + def __getitem__(self, idx) -> InsertedAtoms: + """Select matching position, atom, charge, and batch entries.""" + return InsertedAtoms( + self.x[idx], + self.a[idx], + self.c[idx], + None if self.batch is None else self.batch[idx], + ) + + +def insertion_pair_indices( + ins_batch: torch.Tensor, + spawn_orig_indices: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Build inserted-atom pairs and both endpoints' spawn-node indices. + + ``ins_batch`` is the per-inserted-atom graph id and ``spawn_orig_indices`` the + per-inserted-atom original spawn-node index (both length ``N_ins``, aligned). + ``ins_batch`` must be graph-contiguous, as it is in a :class:`ChemBatch`. + Pairs are emitted per graph in ascending graph order. Returns four empty + long tensors when no graph holds at least two inserted atoms. + """ + if ins_batch.numel() == 0: + empty = ins_batch.new_zeros(0, dtype=torch.long) + return empty, empty.clone(), empty.clone(), empty.clone() + + counts = torch.bincount(ins_batch) + max_count = int(counts.max()) + if max_count < 2: + empty = ins_batch.new_zeros(0, dtype=torch.long) + return empty, empty.clone(), empty.clone(), empty.clone() + + # Build one local upper triangle, then retain the valid prefix for each + # graph. This avoids one GPU launch and Python iteration per graph while + # using O(num_graphs * max_insertions_per_graph**2) temporary storage rather + # than an O(total_insertions**2) cross-graph matrix. + local_src, local_dst = torch.triu_indices( + max_count, max_count, offset=1, device=ins_batch.device + ) + graph_idx, pair_idx = torch.where(local_dst.unsqueeze(0) < counts.unsqueeze(1)) + offsets = counts.cumsum(0) - counts + src = offsets[graph_idx] + local_src[pair_idx] + dst = offsets[graph_idx] + local_dst[pair_idx] + return src, dst, spawn_orig_indices[src], spawn_orig_indices[dst] + + +def sample_edge_types(edge_dist: torch.Tensor, num_edges: int) -> torch.Tensor: + """Draw ``num_edges`` edge-type token indices iid from a categorical prior. + + Helper for sampling new edge types *outside* the join, then passing the + resulting tensor into :func:`join_with_atoms`. + """ + return torch.multinomial(edge_dist, num_edges, replacement=True) + + +def _new_atom_edge_count(num_mol: int, num_atom: int) -> int: + """Number of new upper-triangular edges when ``num_atom`` atoms join a mol.""" + return num_mol * num_atom + num_atom * (num_atom - 1) // 2 + + +def count_new_atom_edges(mol_batch: ChemBatch, atoms_batch: ChemBatch) -> int: + """Total new edges :func:`join_with_atoms` will create across the batch. + + The caller uses this to know how many edge types to sample (e.g. via + :func:`sample_edge_types`) before calling :func:`join_with_atoms`. + """ + mol_n = mol_batch.num_nodes_per_graph.to(torch.long) + atom_n = atoms_batch.num_nodes_per_graph.to(torch.long) + return int((mol_n * atom_n + atom_n * (atom_n - 1) // 2).sum()) + + +def join_with_atoms( + mol_batch: ChemBatch, + atoms_batch: ChemBatch, + new_edge_types: torch.Tensor, +) -> ChemBatch: + """Join a batch of molecules with a batch of new atoms (per graph). + + Each new atom set is + fully connected to its molecule. Edge sampling is *not* done here: the caller + samples the new edge types beforehand (e.g. ``new_edge_types = + sample_edge_types(edge_dist, count_new_atom_edges(mol_batch, atoms_batch))``) + and passes the flat tensor in. It holds one type per new upper-triangular + edge, concatenated graph by graph in (mol->atom, then atom->atom) order. + + The graph is assembled in the concatenated ``[existing, inserted]`` node space + and re-batched via :func:`batch_from_flat` (which restores graph-contiguous + ordering and per-graph offsets) -- the same flat-tensor path as + :func:`join_with_predicted_edges`, so no per-graph ``ChemAtomicData`` objects + are constructed. The small per-graph loop only assembles edge *indices* (cheap + tensor ops) in the order ``new_edge_types`` is laid out. + """ + device = mol_batch["positions"].device + num_existing = mol_batch.num_nodes + num_graphs = mol_batch.num_graphs + mol_batch_idx = mol_batch.batch_idx.long() + atoms_batch_idx = atoms_batch.batch_idx.long() + + positions = torch.cat([mol_batch["positions"], atoms_batch["positions"]]) + atom_types = torch.cat([mol_batch["atom_types"], atoms_batch["atom_types"]]) + charge_types = None + if "charge_types" in mol_batch and "charge_types" in atoms_batch: + charge_types = torch.cat( + [mol_batch["charge_types"], atoms_batch["charge_types"]] + ) + new_batch = torch.cat([mol_batch_idx, atoms_batch_idx]) + + if mol_batch.edge_index.numel() > 0: + existing_edge_index = mol_batch.edge_index + existing_e = mol_batch["edge_types"] + else: + existing_edge_index = torch.empty((2, 0), dtype=torch.long, device=device) + existing_e = torch.empty((0,), dtype=torch.long, device=device) + + # New upper-tri edges per graph, in the combined node space ([mol nodes, + # then inserted atoms]) in count order (mol->atom block, + # then atom->atom) so ``new_edge_types`` is consumed graph by graph. + triu_list: list[torch.Tensor] = [] + e_list: list[torch.Tensor] = [] + start = 0 + for g in range(num_graphs): + mol_g = torch.where(mol_batch_idx == g)[0] + atom_g = torch.where(atoms_batch_idx == g)[0] + num_existing + num_mol = int(mol_g.numel()) + num_atom = int(atom_g.numel()) + count = _new_atom_edge_count(num_mol, num_atom) + if count == 0: + continue + blocks: list[torch.Tensor] = [] + if num_mol > 0 and num_atom > 0: + blocks.append(torch.cartesian_prod(mol_g, atom_g).t()) # mol -> atom + if num_atom > 1: + atom_atom = torch.cartesian_prod(atom_g, atom_g).t() + atom_atom = atom_atom[:, atom_atom[0] < atom_atom[1]] + if atom_atom.shape[1] > 0: + blocks.append(atom_atom) + triu_list.append(torch.cat(blocks, dim=1)) + e_list.append(new_edge_types[start : start + count].to(device, torch.long)) + start += count + + assert start == int(new_edge_types.shape[0]), ( + f"expected {start} edge types, got {new_edge_types.shape[0]}; " + "sample count_new_atom_edges(mol_batch, atoms_batch) of them" + ) + + if triu_list: + new_triu = torch.cat(triu_list, dim=1) + new_e_triu = torch.cat(e_list, dim=0) + new_edge_index, (new_e,) = EDGE_ALIGNER.symmetrize_edges( + new_triu, [new_e_triu], num_nodes=positions.shape[0] + ) + combined_edge_index = torch.cat([existing_edge_index, new_edge_index], dim=1) + combined_e = torch.cat([existing_e, new_e], dim=0) + else: + combined_edge_index, combined_e = existing_edge_index, existing_e + + # keep_mask extends with zeros for the new atoms; system properties + # graph-level properties are carried unchanged. + node_extras: dict[str, torch.Tensor] = {} + if "keep_mask" in mol_batch: + keep_mask = mol_batch["keep_mask"] + node_extras["keep_mask"] = torch.cat( + [ + keep_mask, + torch.zeros( + atoms_batch.num_nodes, dtype=keep_mask.dtype, device=device + ), + ] + ) + system_extras = {key: mol_batch[key] for key in mol_batch.keys["system"]} + + return batch_from_flat( + positions, + atom_types, + charge_types, + combined_e, + combined_edge_index, + new_batch, + num_graphs=num_graphs, + _node_counts=mol_batch.num_nodes_per_graph + atoms_batch.num_nodes_per_graph, + node_extras=node_extras, + system_extras=system_extras, + ) + + +def join_with_predicted_edges( + mol_batch: ChemBatch, + new_atoms_batch: ChemBatch, + e_ins: torch.Tensor, + spawn_node_idx: torch.Tensor, + target_node_idx: torch.Tensor, + *, + e_ins_to_ins: torch.Tensor | None = None, + ins_to_ins_src_idx: torch.Tensor | None = None, + ins_to_ins_dst_idx: torch.Tensor | None = None, + sample_fallback_edge_types: Callable[[int], torch.Tensor] | None = None, +) -> ChemBatch: + """Join molecules with newly inserted atoms using model-predicted edges. + + ``e_ins`` are + the predicted (new-atom -> existing) bond types with ``spawn_node_idx`` + (local new-atom ids) and ``target_node_idx`` (existing-node ids in + ``mol_batch``). New-atom -> new-atom bonds come from ``e_ins_to_ins`` / + ``ins_to_ins_src_idx`` / ``ins_to_ins_dst_idx`` when given; otherwise every + intra-graph pair is created and typed by ``sample_fallback_edge_types`` + (outsourced, like :func:`join_with_atoms`). The graph is assembled in the + concatenated [existing, inserted] space and re-batched via + :func:`batch_from_flat`, which restores graph-contiguous ordering. + """ + device = mol_batch["positions"].device + num_existing = mol_batch.num_nodes + new_atoms_batch_idx = new_atoms_batch.batch_idx + + positions = torch.cat([mol_batch["positions"], new_atoms_batch["positions"]]) + atom_types = torch.cat([mol_batch["atom_types"], new_atoms_batch["atom_types"]]) + charge_types = None + if "charge_types" in mol_batch and "charge_types" in new_atoms_batch: + charge_types = torch.cat( + [mol_batch["charge_types"], new_atoms_batch["charge_types"]] + ) + new_batch = torch.cat([mol_batch.batch_idx, new_atoms_batch_idx]) + + if mol_batch.edge_index.numel() > 0: + existing_edge_index = mol_batch.edge_index + existing_e = mol_batch["edge_types"] + else: + existing_edge_index = torch.empty((2, 0), dtype=torch.long, device=device) + existing_e = torch.empty((0,), dtype=torch.long, device=device) + + triu_list: list[torch.Tensor] = [] + e_list: list[torch.Tensor] = [] + + # inserted -> existing (upper triangle: target precedes the new atom) + if e_ins is not None and e_ins.numel() > 0: + global_new_atom_idx = spawn_node_idx + num_existing + triu_list.append(torch.stack([target_node_idx, global_new_atom_idx], dim=0)) + e_list.append(e_ins.to(device=device, dtype=torch.long)) + + # inserted -> inserted + use_predicted_ii = ( + e_ins_to_ins is not None + and e_ins_to_ins.numel() > 0 + and ins_to_ins_src_idx is not None + and ins_to_ins_dst_idx is not None + ) + if use_predicted_ii: + triu_list.append( + torch.stack( + [ins_to_ins_src_idx + num_existing, ins_to_ins_dst_idx + num_existing], + dim=0, + ) + ) + e_list.append(e_ins_to_ins.to(device=device, dtype=torch.long)) + else: + local_idx = torch.arange(new_atoms_batch.num_nodes, device=device) + src, dst, _, _ = insertion_pair_indices(new_atoms_batch_idx, local_idx) + if src.numel() > 0: + if sample_fallback_edge_types is None: + raise ValueError( + "sample_fallback_edge_types is required when predicted " + "inserted->inserted edges are not provided" + ) + triu_list.append( + torch.stack([src + num_existing, dst + num_existing], dim=0) + ) + e_list.append( + sample_fallback_edge_types(src.numel()).to( + device=device, dtype=torch.long + ) + ) + + if triu_list: + new_triu = torch.cat(triu_list, dim=1) + new_e_triu = torch.cat(e_list, dim=0) + new_edge_index, (new_e,) = EDGE_ALIGNER.symmetrize_edges( + new_triu, [new_e_triu], num_nodes=positions.shape[0] + ) + combined_edge_index = torch.cat([existing_edge_index, new_edge_index], dim=1) + combined_e = torch.cat([existing_e, new_e], dim=0) + else: + combined_edge_index, combined_e = existing_edge_index, existing_e + + # Conditioning: keep_mask extends with zeros for new atoms. + node_extras: dict[str, torch.Tensor] = {} + if "keep_mask" in mol_batch: + keep_mask = mol_batch["keep_mask"] + node_extras["keep_mask"] = torch.cat( + [ + keep_mask, + torch.zeros( + new_atoms_batch.num_nodes, + dtype=keep_mask.dtype, + device=device, + ), + ] + ) + system_extras = {key: mol_batch[key] for key in mol_batch.keys["system"]} + return batch_from_flat( + positions, + atom_types, + charge_types, + combined_e, + combined_edge_index, + new_batch, + num_graphs=mol_batch.num_graphs, + _node_counts=mol_batch.num_nodes_per_graph + + new_atoms_batch.num_nodes_per_graph, + node_extras=node_extras, + system_extras=system_extras, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/insertion_targets.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/insertion_targets.py new file mode 100644 index 0000000000000000000000000000000000000000..e0d32eef34d675e0ae42db78aacee9e8ecf9dd40 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/insertion_targets.py @@ -0,0 +1,160 @@ +"""Structured insertion supervision and its two independent node index spaces.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, replace +from itertools import accumulate + +import torch + + +class _TensorRecord: + """Tensor operations shared by the nested insertion records.""" + + def clone(self): + """Copy every tensor, including the nested bond records.""" + return replace( + self, + **{field.name: getattr(self, field.name).clone() for field in fields(self)}, + ) + + def to_inplace(self, device, non_blocking: bool = True): + """Move all tensors while preserving integer index dtypes.""" + for field in fields(self): + value = getattr(self, field.name) + if isinstance(value, _TensorRecord): + value.to_inplace(device, non_blocking=non_blocking) + else: + setattr(self, field.name, value.to(device, non_blocking=non_blocking)) + return self + + def pin_memory(self): + """Pin every tensor for DataLoader's asynchronous device transfer.""" + for field in fields(self): + setattr(self, field.name, getattr(self, field.name).pin_memory()) + return self + + +@dataclass +class BondsToExisting(_TensorRecord): + """Target bonds connecting a newly inserted atom to an already-existing atom. + + One aligned entry per supervised bond. ``spawn_idx`` / ``existing_idx`` index + the interpolated graph (``mol_t``); ``inserted_idx`` indexes the insertion + atoms (:class:`InsertionTargets` nodes). + """ + + spawn_idx: torch.Tensor # existing node that spawned the inserted endpoint + existing_idx: torch.Tensor # the existing endpoint + inserted_idx: torch.Tensor # the inserted endpoint (local) + bond_type: torch.Tensor # target bond token + + +@dataclass +class BondsBetweenInserted(_TensorRecord): + """Target bonds between two newly inserted atoms (upper triangle, src < dst). + + ``spawn_*`` index the interpolated graph (``mol_t``); ``src_idx`` / ``dst_idx`` + index the insertion atoms. + """ + + spawn_src_idx: torch.Tensor # existing node that spawned the src inserted atom + spawn_dst_idx: torch.Tensor # existing node that spawned the dst inserted atom + src_idx: torch.Tensor # src inserted atom (local) + dst_idx: torch.Tensor # dst inserted atom (local) + bond_type: torch.Tensor + + +@dataclass +class InsertionTargets(_TensorRecord): + """Supervision for the atoms a model must insert, for one graph or a batch. + + ``positions`` / ``atom_types`` / ``charge_types`` are the future-inserted + atoms. ``spawn_node_idx`` maps each inserted atom to the existing node it is + spawned from (in ``mol_t``) -- a one-to-many ``insertion -> nearest node`` + map used by the GMM head and the insertion-edge supervision. The two bond + groups carry per-bond edge-type targets. + + ``nearest_node_idx`` / ``nearest_ins_idx`` are the *inverse*, one-to-one + ``node -> nearest insertion`` assignment used by the unimodal insertion head: + for each existing node that spawns at least one insertion, ``nearest_node_idx`` + holds the node (in ``mol_t``) and ``nearest_ins_idx`` the single nearest + pending insertion assigned to it (indexing these inserted atoms). Each node + appears at most once, so the unimodal head has exactly one target per node. + """ + + positions: torch.Tensor + atom_types: torch.Tensor + charge_types: torch.Tensor + spawn_node_idx: torch.Tensor + bonds_to_existing: BondsToExisting + bonds_between_inserted: BondsBetweenInserted + nearest_node_idx: torch.Tensor + nearest_ins_idx: torch.Tensor + + @property + def num_nodes(self) -> int: + """Number of future-inserted atoms.""" + return int(self.positions.shape[0]) + + @staticmethod + def empty(positions, atom_types, charge_types, device) -> InsertionTargets: + """An insertion target with inserted atoms but no supervised bonds.""" + e = torch.empty((0,), device=device, dtype=torch.long) + return InsertionTargets( + positions=positions, + atom_types=atom_types, + charge_types=charge_types, + spawn_node_idx=e, + bonds_to_existing=BondsToExisting(e, e, e, e), + bonds_between_inserted=BondsBetweenInserted(e, e, e, e, e), + nearest_node_idx=e, + nearest_ins_idx=e, + ) + + +def collate_insertion_targets( + targets: list[InsertionTargets], current_node_counts: list[int] +) -> InsertionTargets: + """Concatenate supervision without modifying the input samples. + + Spawn/existing indices receive offsets in the current molecular batch; + inserted indices receive offsets in the concatenated future atoms. Empty + insertion sets still contribute their current graph's node count. + """ + if not targets or len(targets) != len(current_node_counts): + raise ValueError("Expected one insertion target per current graph") + node_offsets = list(accumulate(current_node_counts, initial=0))[:-1] + ins_offsets = list(accumulate((t.num_nodes for t in targets), initial=0))[:-1] + + def cat(records, name, offsets=None): + values = [getattr(record, name) for record in records] + if offsets is not None: + values = [ + value + offset for value, offset in zip(values, offsets, strict=True) + ] + return torch.cat(values) + + existing = [t.bonds_to_existing for t in targets] + inserted = [t.bonds_between_inserted for t in targets] + return InsertionTargets( + positions=cat(targets, "positions"), + atom_types=cat(targets, "atom_types"), + charge_types=cat(targets, "charge_types"), + spawn_node_idx=cat(targets, "spawn_node_idx", node_offsets), + nearest_node_idx=cat(targets, "nearest_node_idx", node_offsets), + nearest_ins_idx=cat(targets, "nearest_ins_idx", ins_offsets), + bonds_to_existing=BondsToExisting( + spawn_idx=cat(existing, "spawn_idx", node_offsets), + existing_idx=cat(existing, "existing_idx", node_offsets), + inserted_idx=cat(existing, "inserted_idx", ins_offsets), + bond_type=cat(existing, "bond_type"), + ), + bonds_between_inserted=BondsBetweenInserted( + spawn_src_idx=cat(inserted, "spawn_src_idx", node_offsets), + spawn_dst_idx=cat(inserted, "spawn_dst_idx", node_offsets), + src_idx=cat(inserted, "src_idx", ins_offsets), + dst_idx=cat(inserted, "dst_idx", ins_offsets), + bond_type=cat(inserted, "bond_type"), + ), + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/lightning_datamodule.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/lightning_datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..dabc2f4830d809b29512683662d0f186bc0d120d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/lightning_datamodule.py @@ -0,0 +1,201 @@ +import hydra +import pytorch_lightning as pl +import torch +from omegaconf import DictConfig +from torch.utils.data import DataLoader + +from morph.dataset.core.flow_matching_wrapper import ( + FlowMatchingDatasetWrapper, + eval_collate_fn, + train_collate_fn, + worker_init_fn, +) +from morph.dataset.core.representation import Representation + + +class LightningDataModule(pl.LightningDataModule): + """Build flow-matching datasets and their Lightning data loaders.""" + + wrapper_cls = FlowMatchingDatasetWrapper + train_collate_fn = staticmethod(train_collate_fn) + eval_collate_fn = staticmethod(eval_collate_fn) + + def __init__( + self, + vocab: DictConfig, + distributions: DictConfig, + datasets: DictConfig, + interpolator: DictConfig, + num_workers: DictConfig, + batch_size: DictConfig, + representation: str | Representation = Representation.MOLECULE, + n_atoms_strategy: str = "uniform", + uniform_n_atoms_padding: int = 5, + time_dist: DictConfig = None, + rotate: bool = False, + n_augmentations: int = 1, + noise_distributions=None, + train_prefetch_factor: int = 4, + ): + super().__init__() + self.vocab = vocab + self.distributions = distributions + self.datasets = datasets + self.num_workers = num_workers + if train_prefetch_factor < 1: + raise ValueError("train_prefetch_factor must be positive") + self.train_prefetch_factor = train_prefetch_factor + self.batch_size = batch_size + self.representation = Representation(representation) + self.n_atoms_strategy = n_atoms_strategy + self.uniform_n_atoms_padding = int(uniform_n_atoms_padding) + self.rotate = rotate + self.n_augmentations = max(1, int(n_augmentations)) + + if ( + self.n_augmentations > 1 + and self.batch_size.train % self.n_augmentations != 0 + ): + raise ValueError( + f"batch_size.train ({self.batch_size.train}) must be divisible by " + f"n_augmentations ({self.n_augmentations})." + ) + + if time_dist is None: + time_dist = DictConfig( + { + "_target_": "torch.distributions.Uniform", + "low": 0.0, + "high": 1.0, + } + ) + self.time_dist = hydra.utils.instantiate(time_dist) + + self.train_dataset = None + self.val_datasets = None + self.test_datasets = None + self.interpolator = hydra.utils.instantiate( + interpolator, + distributions=self.distributions, + noise_distributions=noise_distributions, + ) + + def _wrapper_kwargs(self, stage: str) -> dict: + is_train = stage == "train" + return { + "distributions": self.distributions, + "interpolator": self.interpolator, + "vocab": self.vocab, + "representation": self.representation, + "n_atoms_strategy": self.n_atoms_strategy, + "uniform_n_atoms_padding": self.uniform_n_atoms_padding, + "time_dist": self.time_dist, + "stage": stage, + "rotate": self.rotate if is_train else False, + "n_augmentations": self.n_augmentations if is_train else 1, + } + + def _wrap_dataset(self, dataset_cfg, stage: str): + base_dataset = hydra.utils.instantiate( + dataset_cfg.copy(), + vocab=self.vocab, + distributions=self.distributions, + split=stage, + ) + return self.wrapper_cls( + base_dataset=base_dataset, + **self._wrapper_kwargs(stage), + ) + + def setup(self, stage=None): + """Construct only the datasets required by *stage*, once.""" + if self.vocab is None: + raise ValueError("vocab is required before setup()") + + if stage in (None, "fit") and self.train_dataset is None: + self.train_dataset = self._wrap_dataset(self.datasets.train, "train") + if stage in (None, "fit", "validate") and self.val_datasets is None: + self.val_datasets = [ + self._wrap_dataset(cfg, "val") for cfg in self.datasets.val + ] + if stage in (None, "test", "predict") and self.test_datasets is None: + self.test_datasets = [ + self._wrap_dataset(cfg, "test") for cfg in self.datasets.test + ] + + def transfer_batch_to_device(self, batch, device, dataloader_idx): + """Move batch to *device* with ``non_blocking=True``. + + Lightning's default tensor traversal does not recurse into Morph or PyG + graph containers. Handle both explicitly so their tensors benefit from + the asynchronous transfer enabled by ``pin_memory=True``. + """ + + def _move(item): + if hasattr(item, "to_inplace"): + return item.to_inplace(device) + if isinstance(item, torch.Tensor) or hasattr(item, "stores"): + return item.to(device, non_blocking=True) + return item + + if isinstance(batch, (tuple, list)): + return type(batch)(_move(item) for item in batch) + return _move(batch) + + def configure_model(self, cfg): + """Publish any dataset-derived conditioning dimensions before model creation.""" + + def train_dataloader(self): + """Return the shuffled training loader.""" + nw = self.num_workers.train + # Each __getitem__ produces n_augmentations training items, so we + # request batch_size.train // n_augmentations unique molecules per + # batch and the wrapper expands them to the configured effective size. + per_load_batch_size = self.batch_size.train // self.n_augmentations + return DataLoader( + self.train_dataset, + shuffle=True, + batch_size=per_load_batch_size, + num_workers=nw, + persistent_workers=nw > 0, + pin_memory=True, + drop_last=True, + collate_fn=self.train_collate_fn, + worker_init_fn=worker_init_fn, + prefetch_factor=self.train_prefetch_factor if nw > 0 else None, + ) + + def _eval_dataloaders(self, datasets, stage: str, drop_last: bool): + nw = self.num_workers[stage] + return [ + DataLoader( + dataset, + shuffle=False, + sampler=self._eval_sampler(dataset), + batch_size=self.batch_size[stage], + num_workers=nw, + persistent_workers=nw > 0, + pin_memory=True, + drop_last=drop_last, + collate_fn=self.eval_collate_fn, + worker_init_fn=worker_init_fn, + prefetch_factor=2 if nw > 0 else None, + ) + for dataset in datasets + ] + + def val_dataloader(self): + """Return validation loaders.""" + return self._eval_dataloaders(self.val_datasets, "val", drop_last=True) + + def _eval_sampler(self, dataset): + """Let Lightning shard evaluation unless a subclass overrides this hook.""" + return None + + def test_dataloader(self): + """Return test loaders.""" + return self._eval_dataloaders(self.test_datasets, "test", drop_last=False) + + def predict_dataloader(self) -> list[DataLoader]: + """Reuse test loaders for prediction.""" + return self.test_dataloader() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/lmdb.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..1f9781bdc4f26c81083c9110ff8620c94541ea4f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/lmdb.py @@ -0,0 +1,270 @@ +"""Molecular LMDB storage, streaming preprocessing, and process-local readers.""" + +from __future__ import annotations + +import json +import os +import pickle +import tempfile +from contextlib import nullcontext +from functools import partial +from multiprocessing import get_context +from pathlib import Path +from typing import TYPE_CHECKING + +import lmdb +from torch.utils.data import Dataset +from torch_geometric.data import Data +from tqdm import tqdm + +if TYPE_CHECKING: + from collections.abc import Iterable + + from morph.dataset.core.vocab import Distributions, Vocab + +SPLITS = ("train", "val", "test") +HYDROGEN_MODES = ("explicit", "implicit") +RECORD_FIELDS = ("z", "pos", "charges", "edge_index", "edge_attr", "smiles") +LEN_KEY = b"__len__" # Cannot collide with 8-byte integer record keys. +LMDB_MAP_SIZE = 128 * 1024**3 # Virtual address space; disk usage grows with data. + + +def _int_key(index: int) -> bytes: + return index.to_bytes(8, "big") + + +def mol_to_bytes(data: Data) -> bytes: + """Store raw fields without PyG object or collated tensor-storage overhead.""" + return pickle.dumps({key: getattr(data, key) for key in RECORD_FIELDS}, protocol=4) + + +def mol_from_bytes(raw: bytes) -> Data: + """Decode one stored molecular graph.""" + return Data(**pickle.loads(raw)) + + +def open_read_env(path: str) -> lmdb.Environment: + """Open immutable data for concurrent random access by worker processes.""" + return lmdb.open( + path, + readonly=True, + lock=False, + readahead=False, + meminit=False, + subdir=True, + max_readers=512, + ) + + +def read_lmdb_length(path: str) -> int: + """Read the record count, with support for caches predating the length key.""" + with open_read_env(path) as env, env.begin() as txn: + length = txn.get(LEN_KEY) + return int(length) if length is not None else txn.stat()["entries"] + + +def _convert_to_bytes(record, converter) -> bytes | None: + data = converter(record) + return None if data is None else mol_to_bytes(data) + + +def write_bytes_to_lmdb( + bytes_iter: Iterable[bytes | None], + lmdb_path: str, + total: int | None = None, + map_size: int = LMDB_MAP_SIZE, + commit_every: int = 10_000, + desc: str = "Writing LMDB", +) -> tuple[int, int]: + """Stream serialized molecule bytes into a new LMDB environment. + + ``None`` entries in the iterator are counted as failures and skipped. + """ + os.makedirs(os.path.dirname(lmdb_path) or ".", exist_ok=True) + env = lmdb.open( + lmdb_path, + map_size=map_size, + subdir=True, + meminit=False, + writemap=False, + max_readers=512, + ) + n_written = 0 + n_failed = 0 + try: + txn = env.begin(write=True) + for out in tqdm(bytes_iter, total=total, desc=desc): + if out is None: + n_failed += 1 + continue + txn.put(_int_key(n_written), out) + n_written += 1 + if n_written % commit_every == 0: + txn.commit() + txn = env.begin(write=True) + txn.put(LEN_KEY, str(n_written).encode()) + txn.commit() + finally: + env.close() + return n_written, n_failed + + +class MolecularLMDBDataset(Dataset): + """Shared dataset options and storage; subclasses implement source loading.""" + + def __init__( + self, + root: str, + split: str = "train", + vocab: Vocab | None = None, + distributions: Distributions | None = None, + preprocess_workers: int | None = 0, + preprocess_chunk_size: int = 128, + hydrogen_mode: str = "explicit", + max_atoms: int | None = None, + allowed_atom_symbols: tuple[str, ...] | None = None, + ): + if split not in SPLITS or hydrogen_mode not in HYDROGEN_MODES: + raise ValueError(f"Expected split in {SPLITS} and mode in {HYDROGEN_MODES}") + if (preprocess_workers or 0) < 0 or preprocess_chunk_size < 1: + raise ValueError("Workers must be non-negative and chunk size positive") + if max_atoms is not None and max_atoms < 1: + raise ValueError("max_atoms must be positive or None") + self.root, self.split = os.fspath(root), split + self.vocab, self.distributions = vocab, distributions + self.preprocess_workers = preprocess_workers or 0 + self.preprocess_chunk_size = preprocess_chunk_size + self.hydrogen_mode = hydrogen_mode + filters = { + "max_atoms": max_atoms, + "allowed_atom_symbols": ( + list(allowed_atom_symbols) if allowed_atom_symbols is not None else None + ), + } + self.processing_options = {**filters, "hydrogen_mode": hydrogen_mode} + self.raw_dir = os.path.join(self.root, "raw") + self.processed_dir = os.path.join(self.root, "processed") + self.lmdb_path = os.path.join(self.processed_dir, f"{split}_data.lmdb") + self._env = self._txn = self._pid = None + + processed = Path(self.processed_dir) + manifest = processed / "preprocessing_summary.json" + metadata = json.loads(manifest.read_text()) if manifest.is_file() else {} + if any(processed.glob("*_data.lmdb")): + # Older caches have only includes_hydrogens, or no metadata (explicit). + cached_mode = metadata.get( + "hydrogen_mode", + "implicit" + if metadata.get("includes_hydrogens") is False + else "explicit", + ) + if cached_mode != hydrogen_mode: + raise ValueError( + f"Requested {hydrogen_mode} H but cache is {cached_mode}: {root}" + ) + cached_filters = metadata.get("processing_options") + if cached_filters is not None and cached_filters != filters: + raise ValueError(f"Cache uses different preprocessing options: {root}") + processed.mkdir(parents=True, exist_ok=True) + if not Path(self.lmdb_path).exists(): + self.prepare() + metadata.update( + hydrogen_mode=hydrogen_mode, + includes_hydrogens=hydrogen_mode == "explicit", + processing_options=filters, + ) + with tempfile.NamedTemporaryFile( + mode="w", dir=processed, delete=False + ) as stream: + json.dump(metadata, stream, indent=2) + os.replace(stream.name, manifest) + self._length = read_lmdb_length(self.lmdb_path) + if not (processed / f"{split}_smiles.txt").is_file(): + self._write_smiles_sidecar() + + def prepare(self) -> None: + """Load and convert a missing source split in dataset subclasses.""" + raise FileNotFoundError(self.lmdb_path) + + def _write_records(self, records, converter, total: int | None = None) -> None: + """Convert in source order and publish a split only after a complete write.""" + convert = partial(_convert_to_bytes, converter=converter) + pool_context = ( + get_context("spawn").Pool(self.preprocess_workers) + if self.preprocess_workers > 1 + else nullcontext() + ) + with ( + tempfile.TemporaryDirectory(dir=self.processed_dir) as staging, + pool_context as pool, + ): + path = str(Path(staging) / "data.lmdb") + outputs = ( + pool.imap(convert, records, chunksize=self.preprocess_chunk_size) + if pool is not None + else map(convert, records) + ) + written, failed = write_bytes_to_lmdb(outputs, path, total=total) + if written == 0: + raise ValueError(f"No valid molecules in {self.split}") + os.rename(path, self.lmdb_path) + print(f"{self.split}: wrote {written} molecules; skipped {failed}") + + def _ensure_txn(self): + """Reuse one transaction per process; reopen handles inherited by fork.""" + if self._env is None or self._pid != os.getpid(): + self.close() + self._env = open_read_env(self.lmdb_path) + self._txn = self._env.begin(buffers=True) + self._pid = os.getpid() + return self._txn + + def close(self) -> None: + """Release this process's transaction and memory mapping.""" + if self._txn is not None: + self._txn.abort() + if self._env is not None: + self._env.close() + self._env = self._txn = self._pid = None + + def __getstate__(self): + """Omit live handles when sending a dataset to spawned DataLoader workers.""" + return {**self.__dict__, "_env": None, "_txn": None, "_pid": None} + + def _write_smiles_sidecar(self) -> None: + """Persist sorted unique SMILES for novelty metrics.""" + with open_read_env(self.lmdb_path) as env, env.begin() as txn: + smiles = { + pickle.loads(txn.get(_int_key(i)))["smiles"] + for i in range(self._length) + } + path = Path(self.processed_dir) / f"{self.split}_smiles.txt" + path.write_text("\n".join(sorted(smiles))) + + def get_all_smiles(self) -> list[str]: + """Read the unique SMILES for this split.""" + return ( + (Path(self.processed_dir) / f"{self.split}_smiles.txt") + .read_text() + .splitlines() + ) + + def iter_bytes(self) -> Iterable[bytes]: + """Read serialized records in source order.""" + txn = self._ensure_txn() + for index in range(self._length): + yield bytes(txn.get(_int_key(index))) + + def __len__(self): + """Return the number of records in this split.""" + return self._length + + def __getitem__(self, index): + """Decode a raw molecular record.""" + if index < 0 or index >= self._length: + raise IndexError(index) + return mol_from_bytes(bytes(self._ensure_txn().get(_int_key(index)))) + + def get(self, index): + """Read raw fields for preprocessing, without tokenized decoding.""" + return self.__getitem__(index) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/preprocessing.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..76a2c1651718564ca4d93ff0cd6a37207dfac378 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/preprocessing.py @@ -0,0 +1,495 @@ +"""Preprocessing class for calculating token distributions from datasets.""" + +import importlib +import os +import tempfile +from multiprocessing import get_context + +import torch +from torch.utils.data import Dataset +from tqdm import tqdm + +from morph.dataset.core.representation import neutral_charge_index +from morph.dataset.core.vocab import Distributions, Vocab +from morph.utils.metrics.tensor import ( + DIST_N_BINS, + RG_N_BINS, + accumulate_pairwise_distance_hist, + accumulate_rog_hist, + dist_edges, + rg_edges, +) +from morph.utils.vocab_utils import z_to_atom_types + +_PREPROCESSING_WORKER = None + + +def _init_preprocessing_worker(dataset, vocab): + global _PREPROCESSING_WORKER # noqa: PLW0603 (process-local pool state) + torch.set_num_threads(1) + _PREPROCESSING_WORKER = _DatasetReduction(dataset, vocab) + + +def _preprocessing_chunk(task): + method, indices = task + return getattr(_PREPROCESSING_WORKER, method)(indices) + + +class _DatasetReduction: + """Read raw records and return mergeable vocabulary/statistics reductions. + + This reader has no cache initialization or file-writing behavior, so pool + workers can construct it directly without bypassing Preprocessing.__init__. + """ + + def __init__(self, dataset: Dataset, vocab: Vocab | None): + self.train_dataset = dataset + self.vocab = vocab + + def _collect_tokens(self, indices): + """Collect vocabulary sets from a range of raw records.""" + + # Extract all atom types and edge types from the dataset in one loop + all_atom_types = set() + all_edge_type_indices = set() + all_charge_tokens = set() + + for i in indices: + data = self.train_dataset.get(i) + + # Extract atom types + atom_types = z_to_atom_types(data.z.tolist()) + all_atom_types.update(atom_types) + + # Extract edge types — only when the source provides bonds. + # Datasets without topology (e.g. TMQM) won't have `edge_attr` at all, + # in which case the canonical edge vocab falls back to [""]. + if ( + hasattr(data, "edge_attr") + and data.edge_attr is not None + and data.edge_attr.numel() > 0 + ): + all_edge_type_indices.update(data.edge_attr.tolist()) + + if hasattr(data, "charges") and data.charges is not None: + all_charge_tokens.update(data.charges.tolist()) + + return all_atom_types, all_edge_type_indices, all_charge_tokens + + def _collect_distribution_counts(self, indices): + """Accumulate mergeable counts and coordinate moments from raw records.""" + # Compute distributions from the provided training dataset only. + dataset = self.train_dataset + + # Streaming counts and coordinate moments avoid retaining every atom and + # an O(N^2) dense adjacency for each molecule. The coordinate recurrence + # is Chan's parallel-variance formula, mathematically identical to the + # sample standard deviation of the old concatenated coordinate tensor. + n_atoms_counts: dict[int, int] = {} + atom_counts = torch.zeros(len(self.vocab.atom_tokens), dtype=torch.long) + edge_counts = torch.zeros(len(self.vocab.edge_tokens), dtype=torch.long) + charge_counts = torch.zeros(len(self.vocab.charge_tokens), dtype=torch.long) + saw_topology = False + saw_charges = False + coord_count = 0 + coord_mean = torch.tensor(0.0, dtype=torch.float64) + coord_m2 = torch.tensor(0.0, dtype=torch.float64) + + atom_to_idx = {token: i for i, token in enumerate(self.vocab.atom_tokens)} + edge_to_idx = {token: i for i, token in enumerate(self.vocab.edge_tokens)} + charge_to_idx = {token: i for i, token in enumerate(self.vocab.charge_tokens)} + + # Pointcloud-mode evaluation targets — computed canonically so the same + # cache serves any representation. + num_atom_types = len(self.vocab.atom_tokens) + pair_hist = torch.zeros( + num_atom_types, num_atom_types, DIST_N_BINS, dtype=torch.float64 + ) + rog_hist = torch.zeros(RG_N_BINS, dtype=torch.float64) + d_edges = dist_edges() + r_edges = rg_edges() + + for i in indices: + data = dataset.get(i) + num_atoms = data.num_nodes + + atom_types = z_to_atom_types(data.z.tolist()) + atom_type_indices_t = torch.tensor( + [atom_to_idx[token] for token in atom_types], dtype=torch.long + ) + atom_counts += atom_type_indices_t.bincount(minlength=atom_counts.numel()) + + # Edge histogram: built only if the dataset provides bonds. Sources + # without topology contribute nothing here (vocab is the singleton + # canonical [NO_BOND] for them anyway). + if ( + getattr(data, "edge_attr", None) is not None + and getattr(data, "edge_index", None) is not None + ): + saw_topology = True + upper = data.edge_index[0] < data.edge_index[1] + upper_types = data.edge_attr[upper].view(-1) + # The source datasets store unique symmetric bonds. Their dense + # upper triangle therefore consists of these bonds plus exactly + # the remaining pairs. + n_pairs = num_atoms * (num_atoms - 1) // 2 + edge_counts[0] += n_pairs - upper_types.numel() + mapped_edges = torch.tensor( + [edge_to_idx[str(int(t))] for t in upper_types.tolist()], + dtype=torch.long, + ) + edge_counts += mapped_edges.bincount(minlength=edge_counts.numel()) + + coord = data.pos - data.pos.mean(dim=0) + flat_coord = coord.double().reshape(-1) + batch_count = flat_coord.numel() + batch_mean = flat_coord.mean() + batch_m2 = ((flat_coord - batch_mean) ** 2).sum() + if coord_count == 0: + coord_mean = batch_mean + coord_m2 = batch_m2 + coord_count = batch_count + else: + delta = batch_mean - coord_mean + total_count = coord_count + batch_count + coord_m2 += ( + batch_m2 + delta.square() * coord_count * batch_count / total_count + ) + coord_mean += delta * batch_count / total_count + coord_count = total_count + + accumulate_pairwise_distance_hist( + pair_hist, coord, atom_type_indices_t, d_edges + ) + accumulate_rog_hist(rog_hist, coord, r_edges) + + if hasattr(data, "charges") and data.charges is not None: + saw_charges = True + charge_type_indices = torch.tensor( + [charge_to_idx[str(token)] for token in data.charges.tolist()], + dtype=torch.long, + ) + charge_counts += charge_type_indices.bincount( + minlength=charge_counts.numel() + ) + + n_atoms_counts[num_atoms] = n_atoms_counts.get(num_atoms, 0) + 1 + + return { + "n_atoms_counts": n_atoms_counts, + "atom_counts": atom_counts, + "edge_counts": edge_counts, + "charge_counts": charge_counts, + "saw_topology": saw_topology, + "saw_charges": saw_charges, + "coord_count": coord_count, + "coord_mean": coord_mean, + "coord_m2": coord_m2, + "pair_hist": pair_hist, + "rog_hist": rog_hist, + } + + +class Preprocessing: + """Load or compute a training vocabulary and reusable dataset statistics. + + A dataset config is instantiated only when an artifact is missing. Numerical + reductions run in _DatasetReduction so workers never initialize cache files. + """ + + NO_BOND_TOKEN = "" + + def __init__( + self, + root: str, + train_dataset: Dataset | dict | None = None, + atom_tokens_path: str = None, + edge_tokens_path: str = None, + charge_tokens_path: str = None, + distributions_path: str = None, + num_workers: int = 0, + chunk_size: int = 2048, + ): + """Use root-level artifact paths unless explicit paths are supplied.""" + if num_workers < 0: + raise ValueError("num_workers must be non-negative") + if chunk_size < 1: + raise ValueError("chunk_size must be positive") + self.num_workers = num_workers + self.chunk_size = chunk_size + self.root = root + self._train_dataset_cfg = train_dataset + self.train_dataset = None + + self.atom_tokens_path = ( + os.path.join(root, "atom_tokens.txt") + if atom_tokens_path is None + else atom_tokens_path + ) + self.edge_tokens_path = ( + os.path.join(root, "edge_tokens.txt") + if edge_tokens_path is None + else edge_tokens_path + ) + self.charge_tokens_path = ( + os.path.join(root, "charge_tokens.txt") + if charge_tokens_path is None + else charge_tokens_path + ) + self.distributions_path = ( + os.path.join(root, "distributions.pt") + if distributions_path is None + else distributions_path + ) + + has_all_files = ( + os.path.exists(self.atom_tokens_path) + and os.path.exists(self.edge_tokens_path) + and os.path.exists(self.charge_tokens_path) + and os.path.exists(self.distributions_path) + ) + + self._artifacts_were_complete = has_all_files + if not has_all_files: + self._ensure_train_dataset() + + self.vocab = None + self.vocab = self._load_or_compute_tokens() + self.distributions = self._load_or_compute_distributions( + force=not self._artifacts_were_complete + ) + + # Release raw records after producing the small artifacts. + self.train_dataset = None + + def _ensure_train_dataset(self): + """Instantiate train dataset lazily only when preprocessing needs it.""" + if self.train_dataset is not None: + return + + if self._train_dataset_cfg is None: + raise ValueError( + "train_dataset is required when preprocessing artifacts are missing." + ) + + if isinstance(self._train_dataset_cfg, Dataset): + self.train_dataset = self._train_dataset_cfg + return + + try: + hydra = importlib.import_module("hydra") + except ImportError as e: + raise ImportError( + "Hydra is required to instantiate train_dataset config lazily." + ) from e + + self.train_dataset = hydra.utils.instantiate(self._train_dataset_cfg) + + def _load_or_compute_tokens(self) -> Vocab: + """Load complete token files, or discover and publish all three together.""" + atom_tokens_exist = os.path.exists(self.atom_tokens_path) + edge_tokens_exist = os.path.exists(self.edge_tokens_path) + charge_tokens_exist = os.path.exists(self.charge_tokens_path) + + if atom_tokens_exist and edge_tokens_exist and charge_tokens_exist: + atom_tokens = self._load_tokens(self.atom_tokens_path) + edge_tokens = self._load_tokens(self.edge_tokens_path) + charge_tokens = self._load_tokens(self.charge_tokens_path) + else: + atom_tokens, edge_tokens, charge_tokens = self._compute_tokens_from_data() + + # Publish the token set together. If a previous preprocessing run + # stopped between files, retaining any old subset can produce a + # vocabulary/distribution mismatch. + self._save_tokens(self.atom_tokens_path, atom_tokens) + self._save_tokens(self.edge_tokens_path, edge_tokens) + self._save_tokens(self.charge_tokens_path, charge_tokens) + + return Vocab( + atom_tokens=atom_tokens, + edge_tokens=edge_tokens, + charge_tokens=charge_tokens, + ) + + def _compute_tokens_from_data(self) -> tuple[list[str], list[str], list[str]]: + """Discover sorted atom, bond, and charge labels from training records.""" + all_atom_types, all_edge_type_indices, all_charge_tokens = set(), set(), set() + for atoms, edges, charges in self._map_chunks("_collect_tokens"): + all_atom_types.update(atoms) + all_edge_type_indices.update(edges) + all_charge_tokens.update(charges) + + tokens = sorted(all_atom_types) + edge_tokens = [self.NO_BOND_TOKEN] + [ + str(int(idx)) for idx in sorted(all_edge_type_indices) + ] + charge_tokens = [str(int(idx)) for idx in sorted(all_charge_tokens)] or ["0"] + return tokens, edge_tokens, charge_tokens + + def _map_chunks(self, method): + """Read disjoint ranges in workers, returning only small reductions.""" + length = len(self.train_dataset) + workers = getattr(self, "num_workers", 0) + if workers <= 1 or length < 2: + reader = _DatasetReduction(self.train_dataset, self.vocab) + yield getattr(reader, method)(range(length)) + return + workers = min(workers, length) + chunk_size = min( + getattr(self, "chunk_size", 2048), + max(1, (length + workers - 1) // workers), + ) + tasks = ( + (method, range(start, min(start + chunk_size, length))) + for start in range(0, length, chunk_size) + ) + with get_context("spawn").Pool( + workers, + initializer=_init_preprocessing_worker, + initargs=(self.train_dataset, self.vocab), + ) as pool: + yield from tqdm( + pool.imap(_preprocessing_chunk, tasks), + total=(length + chunk_size - 1) // chunk_size, + desc=method.removeprefix("_collect_"), + unit="chunk", + ) + + def _save_tokens(self, path: str, tokens: list[str]): + """Publish a token file atomically.""" + parent = os.path.dirname(path) + os.makedirs(parent, exist_ok=True) + tmp_path = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=parent, delete=False) as f: + tmp_path = f.name + for token in tokens: + f.write(f"{token}\n") + os.replace(tmp_path, path) + finally: + if tmp_path is not None and os.path.exists(tmp_path): + os.unlink(tmp_path) + + def _load_tokens(self, path: str) -> list[str]: + """Read one token per line.""" + with open(path, "r") as f: + tokens = [line.strip() for line in f.readlines()] + return tokens + + def _load_or_compute_distributions(self, force: bool = False) -> Distributions: + """Load statistics unless absent or invalidated by a changed vocabulary.""" + if os.path.exists(self.distributions_path) and not force: + distributions = self._load_distributions() + else: + distributions = self._compute_distributions() + + self._save_distributions(distributions) + + distributions = Distributions(**distributions) + return distributions + + def _compute_distributions(self) -> dict[str, torch.Tensor | float]: + """Reduce training statistics in deterministic chunk order.""" + merged = None + for part in self._map_chunks("_collect_distribution_counts"): + if merged is None: + merged = part + continue + for key in ( + "atom_counts", + "edge_counts", + "charge_counts", + "pair_hist", + "rog_hist", + ): + merged[key] += part[key] + for n_atoms, count in part["n_atoms_counts"].items(): + counts = merged["n_atoms_counts"] + counts[n_atoms] = counts.get(n_atoms, 0) + count + merged["saw_topology"] |= part["saw_topology"] + merged["saw_charges"] |= part["saw_charges"] + left, right = merged["coord_count"], part["coord_count"] + if right: + delta = part["coord_mean"] - merged["coord_mean"] + total = left + right + merged["coord_m2"] += ( + part["coord_m2"] + delta.square() * left * right / total + ) + merged["coord_mean"] += delta * right / total + merged["coord_count"] = total + + n_atoms_counts = merged["n_atoms_counts"] + atom_counts = merged["atom_counts"] + edge_counts = merged["edge_counts"] + charge_counts = merged["charge_counts"] + saw_topology = merged["saw_topology"] + saw_charges = merged["saw_charges"] + coord_count = merged["coord_count"] + coord_m2 = merged["coord_m2"] + pair_hist = merged["pair_hist"].float() + rog_hist = merged["rog_hist"].float() + + if not n_atoms_counts or coord_count < 2: + raise ValueError("Cannot compute distributions from an empty dataset") + + n_atoms_distribution = torch.zeros(max(n_atoms_counts) + 1, dtype=torch.float32) + for n_atoms, count in n_atoms_counts.items(): + n_atoms_distribution[n_atoms] = count + n_atoms_distribution = n_atoms_distribution / n_atoms_distribution.sum() + + atom_type_distribution = atom_counts.float() + atom_type_distribution = atom_type_distribution / atom_type_distribution.sum() + + # Canonical edge distribution. Sources without bonds emit no edge entries; + # we fall back to a one-hot at NO_BOND (token 0) so the singleton-canonical + # vocab maps to a proper one-hot prior. + if saw_topology and edge_counts.sum() > 0: + edge_type_distribution = edge_counts.float() + edge_type_distribution = ( + edge_type_distribution / edge_type_distribution.sum().clamp(min=1.0) + ) + else: + edge_type_distribution = torch.zeros(len(self.vocab.edge_tokens)) + edge_type_distribution[0] = 1.0 + + # Canonical charge distribution. Sources without charges emit no entries; + # fall back to one-hot at the canonical "0" index when available. + if saw_charges: + charge_type_distribution = charge_counts.float() + charge_type_distribution = ( + charge_type_distribution / charge_type_distribution.sum() + ) + else: + charge_type_distribution = torch.zeros(len(self.vocab.charge_tokens)) + charge_type_distribution[neutral_charge_index(self.vocab)] = 1.0 + + coordinate_std = torch.sqrt(coord_m2 / (coord_count - 1)).float().item() + + return { + "atom_type_distribution": atom_type_distribution, + "edge_type_distribution": edge_type_distribution, + "charge_type_distribution": charge_type_distribution, + "n_atoms_distribution": n_atoms_distribution, + "coordinate_std": coordinate_std, + "pairwise_distance_histogram": pair_hist, + "radius_of_gyration_histogram": rog_hist, + } + + def _save_distributions( + self, distributions: dict[str, torch.Tensor | float] + ) -> None: + """Save distributions to file.""" + parent = os.path.dirname(self.distributions_path) + os.makedirs(parent, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=parent, delete=False) as f: + tmp_path = f.name + try: + torch.save(distributions, tmp_path) + os.replace(tmp_path, self.distributions_path) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + def _load_distributions(self) -> dict[str, torch.Tensor | float]: + """Load distributions from file.""" + return torch.load(self.distributions_path, weights_only=False) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/properties.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/properties.py new file mode 100644 index 0000000000000000000000000000000000000000..c606bbda163ca46a357d71817de2515485dcb480 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/properties.py @@ -0,0 +1,62 @@ +"""Precomputed per-molecule scalar properties (QED, logP, …) for GEOM/QM9. + +Each property is precomputed once into a float32 ``.npy`` array positionally aligned +to the split's LMDB index and stored at + + /properties//.npy + +so ``__getitem__`` reads a single array element. Property arrays are optional +and supplied separately; ``compute_properties`` defaults to false. Each array +must contain one value per processed molecule in the split's dataset order. +``PROPERTY_FNS`` provides RDKit functions for offline array construction. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import numpy as np +from rdkit.Chem import Crippen +from rdkit.Chem.QED import qed + +if TYPE_CHECKING: + from collections.abc import Callable + + from rdkit import Chem + +PROPERTIES_SUBDIR = "properties" + + +def _qed(rdmol) -> float: + return float(qed(rdmol)) + + +def _logp(rdmol) -> float: + return float(Crippen.MolLogP(rdmol)) + + +# property name -> callable(rdmol) -> float +PROPERTY_FNS: dict[str, Callable[[Chem.Mol], float]] = { + "qed": _qed, + "logp": _logp, +} + + +def property_path(processed_dir: str, property_name: str, split: str) -> str: + """Path of the precomputed ``.npy`` array for one property/split.""" + return os.path.join(processed_dir, PROPERTIES_SUBDIR, property_name, f"{split}.npy") + + +def load_property_array( + processed_dir: str, property_name: str, split: str +) -> np.ndarray: + """Load a precomputed property array (raises with build instructions if absent).""" + path = property_path(processed_dir, property_name, split) + if not os.path.exists(path): + raise FileNotFoundError( + f"Missing precomputed property '{property_name}' for split '{split}' " + f"at {path}. Supply a float32 .npy array with one value per processed " + "molecule in split order, or set compute_properties=False." + ) + return np.load(path) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/representation.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/representation.py new file mode 100644 index 0000000000000000000000000000000000000000..ccfde2cf7c47e9e77338016fa8f8a4269ff8b4a4 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/representation.py @@ -0,0 +1,170 @@ +"""Representation mode for morph training runs. + +Declares what subset of (x, a, c, edge_index, e) a run should train on: +- POINTCLOUD: x, a only (c and edges projected to dummies) +- CHARGED_POINTCLOUD: x, a, c (edges projected to dummies) +- GEOMETRIC_GRAPH: x, a, edge_index, e (charges projected to neutral dummies) +- MOLECULE: full chemistry (no projection) + +A dataset's Capabilities declares what fields it provides. ``validate_representation`` +checks the requested mode is achievable by the dataset. + +The cache and vocab are **canonical** in all modes (one preprocessing per dataset). +The projection helpers transform canonical artifacts into the model-facing view: +- ``project_molecule_to_representation``: canonical ChemAtomicData → model-facing. +- ``project_distributions_to_representation``: canonical priors → projected priors + (used by sample_prior_graph / interpolator / integrator so sampled tokens match + projected targets). +- ``make_uniform_type_prior``: the shared uniform categorical source used by + training and inference, followed by the representation projection above. +""" + +from dataclasses import dataclass, replace +from enum import Enum + +import torch +import torch.nn.functional as F + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.dataset.core.vocab import Distributions, Vocab +from morph.utils.graph_math import build_fully_connected_edge_index + + +class Representation(str, Enum): # noqa: UP042 -- preserve enum string semantics + """Chemical channels exposed to the model after dataset projection.""" + + POINTCLOUD = "pointcloud" + CHARGED_POINTCLOUD = "charged_pointcloud" + GEOMETRIC_GRAPH = "geometric_graph" + MOLECULE = "molecule" + + @property + def requires_charges(self) -> bool: + """Whether charge prediction and supervision are enabled.""" + return self in { + Representation.CHARGED_POINTCLOUD, + Representation.MOLECULE, + } + + @property + def requires_topology(self) -> bool: + """Whether runtime edges represent chemical bonds.""" + return self in { + Representation.GEOMETRIC_GRAPH, + Representation.MOLECULE, + } + + +@dataclass(frozen=True) +class Capabilities: + """Chemical channels available in a dataset's canonical records.""" + + provides_charges: bool + provides_topology: bool + + +def validate_representation(caps: Capabilities, mode: Representation) -> None: + """Raise if the dataset cannot satisfy the requested representation.""" + if mode.requires_charges and not caps.provides_charges: + raise ValueError( + f"Representation {mode} requires charges but dataset does not provide them" + ) + if mode.requires_topology and not caps.provides_topology: + raise ValueError( + f"Representation {mode} requires topology but dataset does not provide it" + ) + + +def neutral_charge_index(vocab: Vocab) -> int: + """Index of the neutral ``"0"`` charge token in the canonical vocab; falls back + to literal 0 when ``"0"`` isn't a discovered token (e.g. TMQM without charges). + The fallback is just a constant the embedding absorbs into bias. + """ + try: + return vocab.charge_tokens.index("0") + except (ValueError, AttributeError): + return 0 + + +def project_molecule_to_representation( + mol: ChemAtomicData, vocab: Vocab, mode: Representation +) -> ChemAtomicData: + """Project canonical ``ChemAtomicData`` onto the run's representation. + + Mutates and returns ``mol`` so any extra attributes (e.g. ``y``) are preserved. + + - non-topology modes: ``neighbor_list = fully_connected(N)``, ``edge_types = + zeros(E)`` (token 0 of canonical edge vocab is ```` by construction). + - non-charge modes: ``charge_types = neutral_idx(N)`` (canonical index of ``"0"``). + - molecule: unchanged. + """ + n = mol.positions.shape[0] + device = mol.positions.device + + if not mode.requires_topology: + edge_index = build_fully_connected_edge_index(n, device=device) + mol.neighbor_list = edge_index.t().contiguous() + mol.edge_types = torch.zeros( + mol.neighbor_list.shape[0], dtype=torch.long, device=device + ) + + if not mode.requires_charges: + mol.charge_types = torch.full( + (n,), neutral_charge_index(vocab), dtype=torch.long, device=device + ) + + return mol + + +def project_distributions_to_representation( + distributions: Distributions, vocab: Vocab, mode: Representation +) -> Distributions: + """Project canonical (or uniform) distributions onto the run's representation. + + Mirrors ``project_molecule_to_representation`` for the priors that + ``sample_prior_graph``, the interpolator's internal Categoricals, and the + integrator's insertion sampler draw from. Pins ``edge_type_distribution`` to + one-hot at the canonical ```` token (index 0) in non-topology modes, + and ``charge_type_distribution`` to one-hot at the neutral charge index in + non-charge modes. Other fields pass through untouched. + """ + new = replace(distributions) + if not mode.requires_topology: + new.edge_type_distribution = F.one_hot( + torch.tensor(0), len(vocab.edge_tokens) + ).float() + if not mode.requires_charges: + new.charge_type_distribution = F.one_hot( + torch.tensor(neutral_charge_index(vocab)), len(vocab.charge_tokens) + ).float() + return new + + +def make_uniform_type_prior( + distributions: Distributions, + vocab: Vocab, + mode: Representation, +) -> Distributions: + """Build the representation-aware uniform categorical flow source. + + Atom, edge, and charge tokens have equal probability before projection. + Channels absent from the selected representation are then pinned to their + inert token. Other statistics, including atom counts and coordinate scale, + remain shared with the canonical training statistics. + """ + prior = replace( + distributions, + atom_type_distribution=torch.full_like( + distributions.atom_type_distribution, + 1.0 / distributions.atom_type_distribution.numel(), + ), + edge_type_distribution=torch.full_like( + distributions.edge_type_distribution, + 1.0 / distributions.edge_type_distribution.numel(), + ), + charge_type_distribution=torch.full_like( + distributions.charge_type_distribution, + 1.0 / distributions.charge_type_distribution.numel(), + ), + ) + return project_distributions_to_representation(prior, vocab, mode) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/tokenized_decode.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/tokenized_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..a1f41f0e658742aa8c5d19d1d22df2904b4937b4 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/tokenized_decode.py @@ -0,0 +1,158 @@ +"""Decode stored molecular conformers into Morph's token representation.""" + +from __future__ import annotations + +import torch + +from morph.dataset.core.atomic_data import make_chem_atomic +from morph.dataset.core.properties import load_property_array +from morph.dataset.core.representation import Capabilities +from morph.utils.rdkit_utils import IDX_BOND_MAP +from morph.utils.vocab_utils import z_to_atom_types + + +def _lookup_tokens( + labels: torch.Tensor, table: torch.Tensor, kind: str +) -> torch.Tensor: + """Reject unrecognized source labels instead of treating them as categories.""" + if ((labels < 0) | (labels >= table.numel())).any(): + raise ValueError(f"Source {kind} labels are absent from the vocabulary") + indices = table[labels] + if (indices < 0).any(): + raise ValueError(f"Source {kind} labels are absent from the vocabulary") + return indices + + +class TokenizedDecodeMixin: + """Stored PyG ``Data`` -> token-index ``ChemAtomicData`` decode, shared by + molecular dataset adapters. + """ + + def _build_token_maps(self, vocab) -> None: + """Build tensor lookup tables for atom, bond, and charge labels.""" + atom_to_idx = {token: i for i, token in enumerate(vocab.atom_tokens)} + z_table = torch.full((119,), -1, dtype=torch.long) + for z, symbol in enumerate(z_to_atom_types(list(range(1, 119))), start=1): + if symbol in atom_to_idx: + z_table[z] = atom_to_idx[symbol] + self._z_to_atom_idx = z_table + + # Stored labels describe bond orders; vocabulary indices describe model + # categories. They differ when a bond order is absent from training or + # the vocabulary has been reordered. + bond_labels = { + token: label + for label, bond in IDX_BOND_MAP.items() + if label != 0 + for token in (str(label), str(bond)) + } + bond_table = torch.full((max(IDX_BOND_MAP) + 1,), -1, dtype=torch.long) + for index, token in enumerate(vocab.edge_tokens): + label = 0 if token == "" else bond_labels[token] + bond_table[label] = index + self._bond_to_idx = bond_table + + charges = torch.tensor([int(token) for token in vocab.charge_tokens]) + self._charge_min = int(charges.min()) + self._charge_to_idx = torch.full( + (int(charges.max()) - self._charge_min + 1,), -1, dtype=torch.long + ) + self._charge_to_idx[charges - self._charge_min] = torch.arange(len(charges)) + + def _load_property_arrays( + self, compute_properties: bool, property_names, split: str + ) -> None: + """Load precomputed per-graph scalar properties (``qed`` / ``logp`` / ...). + + Read by index from ``/properties//.npy`` -- + never per-sample RDKit (which leaks host RAM across forked workers). Off + by default; supply arrays in the processed split's dataset order. + """ + self.compute_properties = compute_properties + self.property_names = tuple(property_names) + self._property_arrays: dict = {} + if self.compute_properties: + self._property_arrays = { + name: load_property_array(self.processed_dir, name, split) + for name in self.property_names + } + + def _build_conditions(self, data, index: int) -> dict: + """Loose conditioning to attach to the graph. Default: the precomputed + scalar properties when ``compute_properties`` is on, else nothing. + Subclasses override to carry extra dataset-specific fields. + """ + if self.compute_properties: + return self._molecular_properties(index, getattr(data, "smiles", "") or "") + return {} + + def _molecular_properties(self, index: int, smiles: str) -> dict: + """Per-graph scalar properties for molecule ``index`` from the precomputed + arrays, as ``[1]`` float tensors (so they batch as system properties), + plus the canonical ``smiles``. Missing SMILES fall back to ``""``. + """ + out: dict = {"smiles": smiles or ""} + for name, arr in self._property_arrays.items(): + out[name] = torch.tensor([float(arr[index])], dtype=torch.float) + return out + + def _decode_to_lean(self, data, index: int): + """Decode one stored, centered conformer into token indices.""" + coord = data.pos - data.pos.mean(dim=0) + if self.distributions.coordinate_std is not None: + coord = coord / self.distributions.coordinate_std + + return make_chem_atomic( + x=coord, + a=_lookup_tokens(data.z.long(), self._z_to_atom_idx, "atom"), + e=_lookup_tokens(data.edge_attr.long(), self._bond_to_idx, "bond"), + c=_lookup_tokens( + data.charges.long() - self._charge_min, self._charge_to_idx, "charge" + ), + edge_index=data.edge_index, + **self._build_conditions(data, index), + ) + + +class TokenizedMolecularDatasetMixin(TokenizedDecodeMixin): + """Adapt a raw molecular reader to flow-matching targets. + + Place this mixin before the reader in the inheritance list. ``get`` always + returns raw fields for preprocessing; indexing requires vocabulary and + coordinate statistics. Source-specific options pass through to the reader. + """ + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root, + vocab=None, + distributions=None, + split="train", + compute_properties: bool = False, + property_names: tuple[str, ...] = ("qed", "logp"), + **source_options, + ): + super().__init__( + root, + split=split, + vocab=vocab, + distributions=distributions, + **source_options, + ) + if vocab is not None: + self._build_token_maps(vocab) + self._load_property_arrays(compute_properties, property_names, split) + + def __getitem__(self, index): + """Decode token labels and normalize the heavy or explicit-H geometry.""" + if self.vocab is None or self.distributions is None: + raise RuntimeError( + "A vocab and distributions are required; use .get() for raw data" + ) + return self._decode_to_lean(super().__getitem__(index), index) + + def get(self, index): + """Read raw fields without dispatching through tokenized indexing.""" + return super().__getitem__(index) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/vocab.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/vocab.py new file mode 100644 index 0000000000000000000000000000000000000000..af28186c1c928a0a56b35e4858f1ddaa525eeb8b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/core/vocab.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass + +import torch + +from morph.utils.vocab_utils import compute_token_weights + + +@dataclass +class Vocab: + """Ordered atom, bond, and charge tokens used by integer labels.""" + + atom_tokens: list[str] + edge_tokens: list[str] + charge_tokens: list[str] + + +@dataclass +class Distributions: + """Empirical token frequencies, size priors, and coordinate statistics.""" + + atom_type_distribution: torch.Tensor + edge_type_distribution: torch.Tensor + charge_type_distribution: torch.Tensor + n_atoms_distribution: torch.Tensor + coordinate_std: float + # Pointcloud-mode evaluation targets in Angstroms (computed on centered, + # un-normalized training coords). Shape (A, A, B) with the (lo, hi) + # atom-type-index convention; None if not computed. + pairwise_distance_histogram: torch.Tensor | None = None + # Shape (B,). None if not computed. + radius_of_gyration_histogram: torch.Tensor | None = None + + +def setup_token_weights( + vocab: Vocab, + distributions: Distributions, + weight_alpha: float, + type_loss_token_weights: str, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Compute atom, edge, and charge token weights from training distributions. + + Returns: + (atom_type_weights, edge_token_weights, charge_token_weights) + """ + atom_type_weights = compute_token_weights( + token_list=vocab.atom_tokens, + distribution=distributions.atom_type_distribution, + special_token_names=[], + weight_alpha=weight_alpha, + type_loss_token_weights=type_loss_token_weights, + ) + edge_token_weights = compute_token_weights( + token_list=vocab.edge_tokens, + distribution=distributions.edge_type_distribution, + special_token_names=[""], + weight_alpha=weight_alpha, + type_loss_token_weights=type_loss_token_weights, + ) + charge_token_weights = compute_token_weights( + token_list=vocab.charge_tokens, + distribution=distributions.charge_type_distribution, + special_token_names=[], + weight_alpha=weight_alpha, + type_loss_token_weights=type_loss_token_weights, + ) + return atom_type_weights, edge_token_weights, charge_token_weights diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6bcfd2cf5eb8e220756c471312279d00685a1f32 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/__init__.py @@ -0,0 +1,8 @@ +"""Unconditional dataset sources. + +Each module ports a raw dataset (download / preprocess into LMDB or an +in-memory PyG dataset) and exposes a ``FlowMatching*`` subclass that +tokenises samples into :class:`~morph.dataset.core.atomic_data.ChemAtomicData`: +GEOM-Drugs (:mod:`geom`), revised QM9 (:mod:`qm9`), materials / MP-20 +(:mod:`materials`), tmQM (:mod:`tmqm`) and Vaska's space (:mod:`vaskas`). +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/chembl3d.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/chembl3d.py new file mode 100644 index 0000000000000000000000000000000000000000..ca3c828c4e642c177dc90d6bd526adc42c5010c4 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/chembl3d.py @@ -0,0 +1,239 @@ +"""Convert LoQI's processed ChEMBL3D release to Morph's LMDB format. + +Source: https://github.com/isayevlab/LoQI (Release 2, Figshare 31441570). +The supplied train/val/test partitions are retained. Atom labels in ``x`` +are decoded using LoQI's FULL_ATOM_ENCODER; auxiliary stereo edges 5--8 +are excluded from chemical topology. Hydrogens, charges and coordinates +in Angstroms are retained, with no GEOM-specific size or element filter. +""" + +from __future__ import annotations + +import hashlib +import os +import tempfile +from functools import partial +from pathlib import Path +from urllib.request import urlopen + +import torch +from rdkit import Chem +from torch_geometric.data import Data +from torch_geometric.data.separate import separate +from tqdm import tqdm + +from morph.dataset.core.conversion import mol_to_data +from morph.dataset.core.lmdb import ( + HYDROGEN_MODES, + MolecularLMDBDataset, +) +from morph.dataset.core.tokenized_decode import TokenizedMolecularDatasetMixin +from morph.utils.rdkit_utils import ( + IDX_BOND_MAP, + sanitize_mol_correctly, +) + +# Pinned to the processed release linked by LoQI, version 1. Only graph +# splits are needed; checkpoints and LoQI's statistics are not downloaded. +RELEASE_FILES = { + "train": (62280814, 6761964617, "391ddd92375ed908527ccfe2e12b83a0"), + "val": (62280805, 845871535, "dc2bd573245520a2bcbc709c252b72cb"), + "test": (62280802, 845962054, "7eacc9247a19b6c0865609d4b0e28364"), +} +ATOM_NUMBERS = torch.tensor( + [1, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 33, 35, 53, 80, 83, 34], + dtype=torch.long, +) +PHYSICAL_EDGE_LABELS = tuple(label for label in IDX_BOND_MAP if label != 0) +STEREO_EDGE_LABELS = (5, 6, 7, 8) # LoQI auxiliary stereo constraints. +GRAPH_FIELDS = ("x", "pos", "charges", "edge_index", "edge_attr") + + +def _download_split(split: str, destination: Path) -> None: + """Download and verify a split before publishing it in the raw cache.""" + file_id, size, checksum = RELEASE_FILES[split] + destination.parent.mkdir(parents=True, exist_ok=True) + url = f"https://ndownloader.figshare.com/files/{file_id}" + digest = hashlib.md5() # Release-provided integrity checksum. + with tempfile.TemporaryDirectory(dir=destination.parent) as staging: + temporary = Path(staging) / destination.name + with ( + urlopen(url, timeout=120) as response, + temporary.open("wb") as output, + tqdm(total=size, unit="B", unit_scale=True, desc=destination.name) as bar, + ): + while chunk := response.read(1024 * 1024): + output.write(chunk) + digest.update(chunk) + bar.update(len(chunk)) + if temporary.stat().st_size != size or digest.hexdigest() != checksum: + raise ValueError(f"Size/checksum mismatch downloading {destination.name}") + os.replace(temporary, destination) + + +def _load_split(path: Path) -> tuple[Data, dict | None, int]: + """Load collated PyG tensors without the per-record InMemoryDataset cache. + + Tensor storage is memory mapped. PyTorch still unpickles the source's + Python metadata (including RDKit molecules); discard it immediately. + Only trusted LoQI release files should be passed to this loader. + """ + payload = torch.load(path, map_location="cpu", weights_only=False, mmap=True) + if not isinstance(payload, tuple) or len(payload) not in (2, 3): + raise ValueError(f"Expected a collated PyG dataset in {path}") + source, slices = payload[:2] + fields = {key: source[key] for key in GRAPH_FIELDS} + data = Data(**fields) + if slices is None: + return data, None, 1 + slices = {key: slices[key] for key in GRAPH_FIELDS} + length = len(slices["x"]) - 1 + if any(len(value) != length + 1 for value in slices.values()): + raise ValueError(f"Inconsistent graph slices in {path}") + return data, slices, length + + +def convert_record( + record: Data, + *, + atom_numbers: torch.Tensor = ATOM_NUMBERS, + stereo_edge_labels: tuple[int, ...] = STEREO_EDGE_LABELS, + hydrogen_mode: str = "explicit", + max_atoms: int | None = None, + allowed_atom_symbols: tuple[str, ...] | None = None, +) -> Data | None: + """Decode a LoQI graph, retaining only valid physical molecular topology. + + Malformed encodings raise an error; invalid chemistry/non-finite geometry + is skipped. Rebuilding from the physical graph avoids dependence on the + optional pickled RDKit molecule and its conformer/stereochemistry state. + """ + if hydrogen_mode not in HYDROGEN_MODES: + raise ValueError(f"hydrogen_mode must be one of {HYDROGEN_MODES}") + atom_types = record.x.long() + n_atoms = atom_types.numel() + if atom_types.shape != (n_atoms,) or n_atoms == 0: + raise ValueError("Expected a nonempty one-dimensional LoQI atom label array") + if (atom_types < 0).any() or (atom_types >= len(atom_numbers)).any(): + raise ValueError("Unknown LoQI atom label") + if record.pos.shape != (n_atoms, 3) or record.charges.shape != (n_atoms,): + raise ValueError("Coordinates/charges do not match the atom count") + if not torch.isfinite(record.pos).all(): + return None + edges = record.edge_index.long() + labels = record.edge_attr.long() + if edges.shape != (2, labels.numel()) or labels.ndim != 1: + raise ValueError("Expected edge_index [2, E] and edge_attr [E]") + physical = torch.isin(labels, labels.new_tensor(PHYSICAL_EDGE_LABELS)) + stereo = torch.isin(labels, labels.new_tensor(stereo_edge_labels)) + if not (physical | stereo).all(): + raise ValueError("Unknown LoQI edge label") + if (edges < 0).any() or (edges >= n_atoms).any(): + raise ValueError("Edge index outside the molecule") + + edges, labels = edges[:, physical], labels[physical] + topology = dict(zip(map(tuple, edges.t().tolist()), labels.tolist(), strict=True)) + if len(topology) != labels.numel() or any( + i == j or topology.get((j, i)) != label for (i, j), label in topology.items() + ): + raise ValueError( + "Physical bonds must be unique, symmetric and without self edges" + ) + + z = atom_numbers[atom_types] + charges = record.charges.long().clone() + # A slice shares storage with the entire split. Clone even when dtype is + # unchanged, or pickle would serialize that whole storage per molecule. + pos = record.pos.float().clone() + editable = Chem.RWMol() + for atomic_number, charge in zip(z.tolist(), charges.tolist(), strict=True): + atom = Chem.Atom(atomic_number) + atom.SetFormalCharge(charge) + atom.SetNoImplicit(True) + editable.AddAtom(atom) + for (i, j), label in topology.items(): + if i < j: + editable.AddBond(i, j, IDX_BOND_MAP[label]) + mol = editable.GetMol() + mol = sanitize_mol_correctly(mol) + if mol is None: + return None + try: + Chem.Kekulize(mol, clearAromaticFlags=True) + conformer = Chem.Conformer(n_atoms) + conformer.SetPositions(pos.double().numpy()) + mol.AddConformer(conformer) + Chem.AssignStereochemistryFrom3D(mol, replaceExistingTags=True) + except (ValueError, RuntimeError): + return None + return mol_to_data( + mol, + hydrogen_mode=hydrogen_mode, + max_atoms=max_atoms, + allowed_atom_symbols=allowed_atom_symbols, + ) + + +def _iter_records(data: Data, slices: dict | None, length: int): + for index in range(length): + if slices is None: + yield data.clone() + else: + # Cloning also keeps multiprocessing from sharing the entire split + # tensor storage when a single graph is sent to a worker. + yield separate(Data, data, index, slices, decrement=False).clone() + + +class ChEMBL3D(MolecularLMDBDataset): + """Read local LoQI release splits or download missing files into raw/.""" + + def __init__(self, root, source_dir: str | None = None, **options): + self.source_dir = source_dir + super().__init__(root, **options) + + def prepare(self) -> None: + """Locate or download the requested release split, then convert it.""" + self.download() + self.process() + + def _source_path(self) -> Path | None: + base = Path(self.source_dir) if self.source_dir is not None else Path(self.root) + for directory in ( + base, + base / "raw", + base / "processed", + base / "chembl3d_stereo" / "processed", + ): + path = directory / f"{self.split}_h.pt" + if path.is_file(): + return path + return None + + def download(self) -> None: + """Reuse supplied files, otherwise download the requested release split.""" + if self._source_path() is not None: + return + if self.source_dir is not None: + raise FileNotFoundError( + f"{self.split}_h.pt not found under source_dir={self.source_dir!r}" + ) + _download_split(self.split, Path(self.raw_dir) / f"{self.split}_h.pt") + + def process(self) -> None: + """Stream the selected split into a new LMDB; never modify source files.""" + source = self._source_path() + if source is None: + raise FileNotFoundError(f"Missing source for {self.split}") + if os.path.exists(self.lmdb_path): + raise FileExistsError(self.lmdb_path) + print(f"Loading {source} (tensor mmap; Python metadata is loaded into RAM)") + data, slices, length = _load_split(source) + self._write_records( + _iter_records(data, slices, length), + partial(convert_record, **self.processing_options), + total=length, + ) + + +class FlowMatchingChEMBL3DDataset(TokenizedMolecularDatasetMixin, ChEMBL3D): + """Decode ChEMBL3D records into tokenized flow-matching targets.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/geom.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/geom.py new file mode 100644 index 0000000000000000000000000000000000000000..6ae3c25506028c6bc451551d4ef6e1fbf4e32b4d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/geom.py @@ -0,0 +1,110 @@ +import os +import pickle +from functools import partial + +from rdkit import Chem +from torch_geometric.data import Data, download_url + +from morph._vendor.geom_drugs_preprocessing import process_geom_drugs +from morph.dataset.core.conversion import mol_to_data +from morph.dataset.core.lmdb import MolecularLMDBDataset +from morph.dataset.core.tokenized_decode import TokenizedMolecularDatasetMixin +from morph.utils.rdkit_utils import ( + sanitize_mol_correctly, +) + +# Source of the raw GEOM-Drugs pickle files used by the GEOM-Drugs Revisited +# paper (Nikitin et al., 2025, arXiv:2505.00169). Three files live here: +# train_data.pickle, val_data.pickle, test_data.pickle. +GEOM_RAW_URL = "https://bits.csb.pitt.edu/files/geom_raw" + +RAW_FILENAMES = { + "train": "train_data.pickle", + "val": "val_data.pickle", + "test": "test_data.pickle", +} + + +def process_one_conformer(mol: Chem.Mol, **options) -> Data | None: + """Sanitize a source conformer, then apply the shared processing settings.""" + if mol is None: + return None + mol = sanitize_mol_correctly(Chem.Mol(mol)) + return mol_to_data(mol, **options) if mol is not None else None + + +class GEOM(MolecularLMDBDataset): + """GEOM-Drugs source processing with shared molecular LMDB storage.""" + + def __init__(self, root, source_dir: str | None = None, **options): + self.source_dir = source_dir + self.filtered_dir = ( + os.fspath(source_dir) + if source_dir is not None + else os.path.join(root, "raw_filtered") + ) + super().__init__(root, **options) + + def prepare(self) -> None: + """Download, prefilter and convert a missing GEOM split.""" + if self.source_dir is None: + self.download() + self.prefilter() + self.process() + + def download(self) -> None: + """Download missing raw split files.""" + os.makedirs(self.raw_dir, exist_ok=True) + for fname in RAW_FILENAMES.values(): + target = os.path.join(self.raw_dir, fname) + if os.path.exists(target): + continue + print(f"Downloading {fname} from {GEOM_RAW_URL} ...") + download_url(f"{GEOM_RAW_URL}/{fname}", self.raw_dir) + + def prefilter(self) -> None: + """Run the upstream GEOM prefilter once per source directory.""" + all_present = all( + os.path.exists(os.path.join(self.filtered_dir, fname)) + for fname in RAW_FILENAMES.values() + ) + if all_present: + return + + print("Applying GEOM-Drugs Revisited pre-filter to all splits ...") + process_geom_drugs(self.raw_dir, self.filtered_dir) + + # Keep empty download sentinels once the filtered copies exist. + for fname in RAW_FILENAMES.values(): + raw_path = os.path.join(self.raw_dir, fname) + filtered_path = os.path.join(self.filtered_dir, fname) + if ( + os.path.exists(filtered_path) + and os.path.exists(raw_path) + and os.path.getsize(raw_path) > 0 + ): + with open(raw_path, "wb"): + pass + print(f" Truncated raw {fname} (filtered copy in {self.filtered_dir})") + + def process(self): + """Process pre-filtered pickle files and write them into LMDB.""" + raw_file = os.path.join(self.filtered_dir, f"{self.split}_data.pickle") + + if not os.path.exists(raw_file): + raise FileNotFoundError(f"Filtered data file not found: {raw_file}") + + print(f"Loading filtered data from {raw_file}...") + with open(raw_file, "rb") as f: + raw_data = pickle.load(f) + + conformers = (mol for _, group in raw_data for mol in group) + self._write_records( + conformers, + partial(process_one_conformer, **self.processing_options), + total=sum(len(group) for _, group in raw_data), + ) + + +class FlowMatchingGEOMDataset(TokenizedMolecularDatasetMixin, GEOM): + """Decode GEOM records into tokenized flow-matching targets.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/qm9.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/qm9.py new file mode 100644 index 0000000000000000000000000000000000000000..834b5b323333fc9ce1c9dc129c749e9d2a1a41d4 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/dataset/unconditional/qm9.py @@ -0,0 +1,270 @@ +import os + +import torch +from rdkit import Chem, RDLogger +from rdkit.Chem.rdchem import HybridizationType +from torch_geometric.data import Data, InMemoryDataset, download_url, extract_zip +from torch_geometric.datasets.qm9 import conversion +from torch_geometric.utils import one_hot, scatter +from tqdm import tqdm + +from morph.dataset.core.representation import Capabilities +from morph.dataset.core.tokenized_decode import TokenizedDecodeMixin +from morph.dataset.core.vocab import Distributions, Vocab +from morph.utils.rdkit_utils import ( + BOND_IDX_MAP, + mol_is_valid, + sanitize_mol_correctly, + smiles_from_mol, +) + +RDLogger.DisableLog("rdApp.*") # type: ignore[attr-defined] + + +class RevisedQM9(InMemoryDataset): + """QM9 with corrected structures from PropMolFlow.""" + + mols_url = "https://zenodo.org/records/15700961/files/all_fixed_gdb9.zip" + props_url = "https://deepchemdata.s3-us-west-1.amazonaws.com/datasets/molnet_publish/qm9.zip" + split_to_index = {"train": 0, "val": 1, "test": 2} + + @property + def raw_file_names(self) -> list[str]: + """Source structure and property files required for preprocessing.""" + return ["all_fixed_gdb9.sdf", "gdb9.sdf.csv"] + + @property + def processed_file_names(self) -> list[str]: + """Serialized records for the train, validation, and test splits.""" + return ["train.pt", "val.pt", "test.pt"] + + def _download(self) -> None: + """Reuse complete processed caches without fetching raw QM9 inputs.""" + if not self.force_reload and all( + os.path.isfile(path) for path in self.processed_paths + ): + return + super()._download() + + def load(self, split: str) -> None: + """Load one named split from the processed cache.""" + if split not in self.split_to_index: + raise ValueError( + f"Invalid split '{split}'. " + f"Expected one of {tuple(self.split_to_index)}." + ) + self._split = split + super().load(self.processed_paths[self.split_to_index[split]]) + + def download(self) -> None: + """Download corrected structures and QM9 regression properties.""" + file_path = download_url(self.mols_url, self.raw_dir) + extract_zip(file_path, self.raw_dir) + os.unlink(file_path) + + file_path = download_url(self.props_url, self.raw_dir) + extract_zip(file_path, self.raw_dir) + + os.unlink(file_path) + # also delete the extra data file in the zip + os.unlink(os.path.join(self.raw_dir, "gdb9.sdf")) + + def process(self) -> None: + """Validate molecules, encode raw graphs, and write dataset splits.""" + types = {"H": 0, "C": 1, "N": 2, "O": 3, "F": 4} + bonds = BOND_IDX_MAP + + with open(self.raw_paths[1]) as f: + target = [ + [float(x) for x in line.split(",")[1:20]] + for line in f.read().split("\n")[1:-1] + ] + y = torch.tensor(target, dtype=torch.float) + y = torch.cat([y[:, 3:], y[:, :3]], dim=-1) + y = y * conversion.view(1, -1) + + suppl = Chem.SDMolSupplier(self.raw_paths[0], removeHs=False, sanitize=False) + + errors = 0 + skipped = 0 + + data_list = [] + for i, mol in enumerate(tqdm(suppl)): + # if i in skip: + # continue + if mol is None or not mol_is_valid(mol): + errors += 1 + continue + + mol = sanitize_mol_correctly(mol) + if mol is None: + errors += 1 + continue + + N = mol.GetNumAtoms() + + conf = mol.GetConformer() + pos = conf.GetPositions() + pos = torch.tensor(pos, dtype=torch.float) + + charges = [] + + type_idx = [] + atomic_number = [] + aromatic = [] + sp = [] + sp2 = [] + sp3 = [] + num_hs = [] + for atom in mol.GetAtoms(): + type_idx.append(types[atom.GetSymbol()]) + atomic_number.append(atom.GetAtomicNum()) + aromatic.append(1 if atom.GetIsAromatic() else 0) + hybridization = atom.GetHybridization() + sp.append(1 if hybridization == HybridizationType.SP else 0) + sp2.append(1 if hybridization == HybridizationType.SP2 else 0) + sp3.append(1 if hybridization == HybridizationType.SP3 else 0) + charges.append(atom.GetFormalCharge()) + + z = torch.tensor(atomic_number, dtype=torch.long) + + rows, cols, edge_types = [], [], [] + for bond in mol.GetBonds(): + start, end = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + rows += [start, end] + cols += [end, start] + edge_types += 2 * [bonds[bond.GetBondType()]] + + edge_index = torch.tensor([rows, cols], dtype=torch.long) + edge_type = torch.tensor(edge_types, dtype=torch.long) + # edge_attr = one_hot(edge_type, num_classes=len(bonds)) + + perm = (edge_index[0] * N + edge_index[1]).argsort() + edge_index = edge_index[:, perm] + edge_type = edge_type[perm] + # edge_attr = edge_attr[perm] + + row, col = edge_index + hs = (z == 1).to(torch.float) + num_hs = scatter(hs[row], col, dim_size=N, reduce="sum").tolist() + + x1 = one_hot(torch.tensor(type_idx), num_classes=len(types)) + x2 = ( + torch.tensor( + [atomic_number, aromatic, sp, sp2, sp3, num_hs], dtype=torch.float + ) + .t() + .contiguous() + ) + x = torch.cat([x1, x2], dim=-1) + + charges = torch.tensor(charges, dtype=torch.int64) + + name = mol.GetProp("_Name") + smiles = smiles_from_mol(mol, canonical=True) or "" + + # TODO exchange with our own MolData object for consistency + data = Data( + x=x, + z=z, + pos=pos, + charges=charges, + edge_index=edge_index, + smiles=smiles, + edge_attr=edge_type, + y=y[i].unsqueeze(0), + name=name, + idx=i, + ) + + if self.pre_filter is not None and not self.pre_filter(data): + continue + if self.pre_transform is not None: + data = self.pre_transform(data) + + data_list.append(data) + + n_train = 100_000 + n_val = 20_000 + + self.save(data_list[:n_train], self.processed_paths[0]) + self.save(data_list[n_train : n_train + n_val], self.processed_paths[1]) + self.save(data_list[n_train + n_val :], self.processed_paths[2]) + + for split_name, split_data in [ + ("train", data_list[:n_train]), + ("val", data_list[n_train : n_train + n_val]), + ("test", data_list[n_train + n_val :]), + ]: + smiles_path = os.path.join(self.processed_dir, f"{split_name}_smiles.txt") + unique_smiles = sorted({d.smiles for d in split_data}) + with open(smiles_path, "w") as f: + f.write("\n".join(unique_smiles)) + + print(f"Errors: {errors}") + print(f"Skipped: {skipped}") + + def get_all_smiles(self) -> list[str]: + """Read SMILES in the loaded split's record order.""" + smiles_path = os.path.join(self.processed_dir, f"{self._split}_smiles.txt") + with open(smiles_path) as f: + return f.read().splitlines() + + +class FlowMatchingQM9Dataset(TokenizedDecodeMixin, RevisedQM9): + """Decode corrected QM9 records into tokenized training targets.""" + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root, + vocab: Vocab, + distributions: Distributions, + transform=None, + pre_transform=None, + split="train", + compute_properties: bool = False, + property_names: tuple[str, ...] = ("qed", "logp"), + ): + super().__init__(root, transform, pre_transform) + self.load(split) + + self.vocab = vocab + self.distributions = distributions + # Token/z-index maps + precomputed property arrays (shared decode adapter). + if vocab is not None: + self._build_token_maps(vocab) + self._load_property_arrays(compute_properties, property_names, self._split) + + def __getitem__(self, index): + """Return a tokenized molecule with its QM9 regression targets.""" + if self.vocab is None: + raise RuntimeError( + "A vocab is required for tokenized QM9 samples; use .get() for raw data" + ) + data = super().__getitem__(index) + mol = self._decode_to_lean(data, index) + + # Carry through the QM9 regression targets if present (2D so per-graph + # batching concatenates along dim 0). + if hasattr(data, "y") and data.y is not None: + mol.add_system_property( + "y", data.y if data.y.dim() == 2 else data.y.unsqueeze(0) + ) + + return mol + + def _build_conditions(self, data, index: int) -> dict: + conditions = super()._build_conditions(data, index) + # When not computing properties, still keep the cheap ground-truth SMILES + # string for downstream eval. + if not self.compute_properties and hasattr(data, "smiles"): + conditions["smiles"] = data.smiles + return conditions + + +if __name__ == "__main__": + _root = os.path.join(os.path.dirname(__file__), "../../../../data/qm9") + qm9 = FlowMatchingQM9Dataset(root=_root) + print(qm9[0]) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d9b707d8cad88bfd9db1a9318b10da933375a2a --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__init__.py @@ -0,0 +1 @@ +"""Flow-matching core: graph priors, interpolation paths, and integration.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09b8ddfc98f1c5673cf03745d9200675f26317e6 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/integration.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/integration.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03913595e12a30e74d05fd2d72a3266255bbc769 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/integration.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/sampling_step.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/sampling_step.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93ed6705778435e60ae03c217a2d4b654037feb9 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/sampling_step.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/schedules.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/schedules.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6aab5057b6c8374d1a28d23c82f12ae79818cb5f Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/__pycache__/schedules.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/assignment.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..64be2a6d5728675c8922c37057ccdaaf3792c679 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/assignment.py @@ -0,0 +1,459 @@ +"""Partial-optimal-transport alignment for flow matching. + +Two layers in one module: + * the data-structure-agnostic assignment maths (cost matrices, Hungarian + solving and MCS constraints) in pure ``numpy`` / ``scipy``; + * thin wrappers operating on + :class:`~morph.dataset.core.atomic_data.ChemAtomicData`. +""" + +from __future__ import annotations + +import numpy as np +import torch +from scipy.optimize import linear_sum_assignment + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.utils.graph_math import rigid_alignment + + +def _pairwise_cost(x0, x1, squared=False): + """Pairwise ground cost between two point sets. + + Euclidean distance (W1 ground cost) by default; squared Euclidean (W2 ground + cost) when ``squared=True``. The W2 cost is consistent with both the + flow-matching straight-line interpolant and the Kabsch (RMSD) alignment, so + the permutation and the rotation then descend the same energy. + """ + diff = x0[:, np.newaxis] - x1[np.newaxis, :] + if squared: + return np.einsum("ijd,ijd->ij", diff, diff) + return np.linalg.norm(diff, axis=2) + + +def distance_based_assignment(x0, x1, squared=False): + """ + Assign targets from x1 to x0 using the Hungarian algorithm. + + Args: + x0 (np.ndarray): Shape (N_x0, D) + x1 (np.ndarray): Shape (N_x1, D) + squared (bool): Use squared-Euclidean (W2) ground cost instead of + Euclidean (W1). + + Returns: + tuple: A tuple of two arrays: + - row_ind (np.ndarray): Shape (N_x0,) + - col_ind (np.ndarray): Shape (N_x1,) + """ + # Calculate the cost matrix *only* for valid items + # (N_valid, 1, D) - (1, M_valid, D) => (N_valid, M_valid, D) + cost_matrix_b = _pairwise_cost(x0, x1, squared=squared) + # cost_matrix_b shape is (N_x0, N_x1) + + # Perform the assignment + # row_ind and col_ind are indices *into* x0 and x1 + row_ind, col_ind = linear_sum_assignment(cost_matrix_b) + # K_b = len(row_ind) + + return row_ind, col_ind + + +def pre_align_positions(x0, x1, num_iterations=5, squared=False): + """Pre-align positions by iterating assignment and Kabsch alignment. + + This is the equivariant-OT inner loop (Klein et al. 2023): alternating the + assignment and the rigid (Kabsch) alignment jointly optimizes permutation + *and* rotation. With ``squared=True`` both steps minimize the same energy + (sum of matched squared distances), so the alternation is well-posed. + """ + for _ in range(num_iterations): + row_ind, col_ind = distance_based_assignment(x0, x1, squared=squared) + x0_tensor = torch.from_numpy(x0) + x1_tensor = torch.from_numpy(x1) + R, t = rigid_alignment(x0_tensor[row_ind], x1_tensor[col_ind]) + x0 = (x0_tensor @ R.T + t).numpy() + + return x0, x1 + + +def distance_and_class_based_assignment( + x0, + x1, + a0, + a1, + c_move=1.0, + c_sub=10.0, + c_ins=5.0, + c_del=10.0, + squared=False, +): + """ + Assign targets from x1 to x0 for a single graph pair using the Hungarian + algorithm on an augmented (move / substitute / insert / delete) cost matrix. + + Special cases: + 1) Distance-based assignment + c_move=const, c_sub=0, c_ins>>c_move + + 2) Class-based assignment. + Note: This will not be useful for our application! + c_move=0, c_sub=const, c_ins>>c_sub + + 3) Pure insertion-deletion assignment - no move. + c_move>>c_ins, c_sub=const, c_ins=const + + Args: + x0 (np.ndarray): Shape (N_x0, D) + x1 (np.ndarray): Shape (N_x1, D) + a0 (np.ndarray): Shape (N_x0, C) + a1 (np.ndarray): Shape (N_x1, C) + c_move (float): Distance cost weight + c_sub (float): Class cost weight + c_ins (float): Insertion cost weight + c_del (float): Deletion cost weight + squared (bool): If True, use squared-Euclidean (W2) move costs. + + Returns: + tuple: A tuple of two arrays: + - row_ind (np.ndarray): Shape (N_x0,) + - col_ind (np.ndarray): Shape (N_x1,) + """ + N = x0.shape[0] + M = x1.shape[0] + + if N == 0 or M == 0: + return np.array([], dtype=int), np.array([], dtype=int) + + # 1. Calculate Real Costs. With ``squared`` the move term is squared-Euclidean + # (W2); c_ins/c_del are then in squared-distance units (so c_ins + c_del is + # the squared move-distance horizon beyond which del+ins beats a move). + dist_matrix = _pairwise_cost(x0, x1, squared=squared) + cost_real = c_move * dist_matrix + if c_sub: + class_diff = (a0[:, np.newaxis] != a1[np.newaxis, :]).astype(float) + cost_real = cost_real + c_sub * class_diff + + # 2. Construct Augmented Cost Matrix (N+M, N+M) + total_size = N + M + aug_cost = np.zeros((total_size, total_size)) + + # [Top-Left] Real matches (N x M) + aug_cost[:N, :M] = cost_real + + # Real atoms may only use their own insertion/deletion dummy. The remaining + # dummy-to-dummy block stays at the zero initialization above. + aug_cost[:N, M:] = 1e8 + np.fill_diagonal(aug_cost[:N, M:], c_del) + aug_cost[N:, :M] = 1e8 + np.fill_diagonal(aug_cost[N:, :M], c_ins) + + # 3. Solve + row_ind, col_ind = linear_sum_assignment(aug_cost) + + return row_ind, col_ind + + +def mcs_constrained_assignment( + x0, + x1, + a0, + a1, + mcs_pairs: list[tuple[int, int]], + c_move=1.0, + c_sub=10.0, + c_ins=5.0, + c_del=10.0, + squared=False, +): + """Run OT on non-MCS atoms and merge with fixed MCS pairs. + + This enforces backbone matches from ``mcs_pairs`` and computes all + remaining assignments with the same partial OT routine used elsewhere. + """ + N = x0.shape[0] + M = x1.shape[0] + + # Keep only valid, one-to-one MCS pairs. + valid_pairs = [] + used_src = set() + used_tgt = set() + for i_s, i_t in mcs_pairs: + if not (0 <= i_s < N and 0 <= i_t < M): + continue + if i_s in used_src or i_t in used_tgt: + continue + valid_pairs.append((i_s, i_t)) + used_src.add(i_s) + used_tgt.add(i_t) + + unmatched_src = [i for i in range(N) if i not in used_src] + unmatched_tgt = [j for j in range(M) if j not in used_tgt] + n_u = len(unmatched_src) + m_u = len(unmatched_tgt) + + # Pair list in the augmented global index spaces: + # source indices in [0, N+M), target indices in [0, M+N) + global_pairs: list[tuple[int, int]] = [] + + # 1) Force MCS real-real pairs. + for i_s, i_t in valid_pairs: + global_pairs.append((i_s, i_t)) + + # 2) Pair corresponding MCS dummies with each other. + # source dummy for target i_t is N + i_t + # target dummy for source i_s is M + i_s + for i_s, i_t in valid_pairs: + global_pairs.append((N + i_t, M + i_s)) + + # 3) Solve the reduced OT for non-MCS atoms only. + if n_u > 0 and m_u > 0: + row_u, col_u = distance_and_class_based_assignment( + x0[np.array(unmatched_src)], + x1[np.array(unmatched_tgt)], + a0[np.array(unmatched_src)], + a1[np.array(unmatched_tgt)], + c_move=c_move, + c_sub=c_sub, + c_ins=c_ins, + c_del=c_del, + squared=squared, + ) + + for r_loc, c_loc in zip(row_u.tolist(), col_u.tolist(), strict=True): + # Map reduced augmented indices to full augmented indices. + if r_loc < n_u: + r_glob = unmatched_src[r_loc] + else: + r_glob = N + unmatched_tgt[r_loc - n_u] + + if c_loc < m_u: + c_glob = unmatched_tgt[c_loc] + else: + c_glob = M + unmatched_src[c_loc - m_u] + + global_pairs.append((r_glob, c_glob)) + elif n_u == 0 and m_u > 0: + # Only insertions remain (all unmatched targets). + global_pairs.extend((N + j, j) for j in unmatched_tgt) + elif n_u > 0 and m_u == 0: + # Only deletions remain (all unmatched sources). + global_pairs.extend((i, M + i) for i in unmatched_src) + + if len(global_pairs) != N + M: + raise ValueError( + "MCS-constrained assignment did not produce a complete matching " + f"(got {len(global_pairs)}, expected {N + M})." + ) + + row_ind = np.array([p[0] for p in global_pairs], dtype=int) + col_ind = np.array([p[1] for p in global_pairs], dtype=int) + + # Safety checks: augmented assignment must be a permutation on both sides. + if len(np.unique(row_ind)) != (N + M) or len(np.unique(col_ind)) != (N + M): + raise ValueError("MCS-constrained assignment produced duplicate indices.") + + return row_ind, col_ind + + +# ---------------------------------------------------------------------- +# ChemAtomicData-coupled wrappers +# ---------------------------------------------------------------------- +def _align_graph( + data: ChemAtomicData, + permutation: torch.Tensor, + *, + positions: torch.Tensor | None = None, +) -> ChemAtomicData: + """Align real nodes with zero-valued auxiliaries in one allocation.""" + num_nodes = data.num_nodes + is_real = permutation < num_nodes + source_idx = permutation[is_real] + output_idx = is_real.nonzero(as_tuple=True)[0] + + def _align(value): + if value is None: + return None + aligned = value.new_zeros((permutation.numel(), *value.shape[1:])) + aligned[output_idx] = value[source_idx] + return aligned + + node_map = torch.full((num_nodes,), -1, dtype=torch.long, device=data.device) + node_map[source_idx] = output_idx + neighbor_list = None if data.neighbor_list is None else node_map[data.neighbor_list] + + extra = dict(data.extra) + for key in data.node_extra_keys: + if isinstance(extra.get(key), torch.Tensor): + extra[key] = _align(extra[key]) + if neighbor_list is not None and neighbor_list.numel() > 0: + for key in data.edge_extra_keys: + if isinstance(extra.get(key), torch.Tensor): + extra[key] = extra[key].clone() + + is_auxiliary = _align(data.is_auxiliary) + if is_auxiliary is None: + is_auxiliary = torch.zeros( + (permutation.numel(), 1), dtype=torch.bool, device=data.device + ) + is_auxiliary[~is_real] = True + + return ChemAtomicData( + positions=_align(data.positions) if positions is None else positions, + atom_types=_align(data.atom_types), + charge_types=_align(data.charge_types), + edge_types=( + data.edge_types.clone() + if data.edge_types is not None and neighbor_list is not None + else data.edge_types + ), + neighbor_list=neighbor_list, + is_auxiliary=is_auxiliary, + extra=extra, + node_extra_keys=data.node_extra_keys, + edge_extra_keys=data.edge_extra_keys, + system_extra_keys=data.system_extra_keys, + smiles=data.smiles, + ) + + +def _align_and_filter( + sample: ChemAtomicData, + target: ChemAtomicData, + row_ind, + col_ind, + optimal_transport: str, +) -> tuple[ChemAtomicData, ChemAtomicData]: + """Shared tail of every alignment routine. + + Align each side under the augmented assignment and drop dummy-dummy pairs. + """ + n, m = sample.num_nodes, target.num_nodes + row_ind = torch.as_tensor(row_ind, dtype=torch.long, device=sample.device) + col_ind = torch.as_tensor(col_ind, dtype=torch.long, device=target.device) + + sample_is_real = row_ind < n + target_is_real = col_ind < m + sample_is_aux = torch.ones(row_ind.numel(), dtype=torch.bool, device=sample.device) + target_is_aux = torch.ones(col_ind.numel(), dtype=torch.bool, device=target.device) + if sample.is_auxiliary is None: + sample_is_aux[sample_is_real] = False + else: + sample_is_aux[sample_is_real] = sample.is_auxiliary[ + row_ind[sample_is_real] + ].squeeze(-1) + if target.is_auxiliary is None: + target_is_aux[target_is_real] = False + else: + target_is_aux[target_is_real] = target.is_auxiliary[ + col_ind[target_is_real] + ].squeeze(-1) + + valid = ~(sample_is_aux & target_is_aux) + sample_positions = sample.positions.new_zeros( + (row_ind.numel(), *sample.positions.shape[1:]) + ) + sample_positions[sample_is_real] = sample.positions[row_ind[sample_is_real]] + + if optimal_transport == "equivariant": + match_mask = ~sample_is_aux & ~target_is_aux + if match_mask.sum() > 0: + target_matches = target.positions[col_ind[match_mask]] + rot, trans = rigid_alignment(sample_positions[match_mask], target_matches) + # Preserve the established arithmetic order: transform the padded + # matrix before dropping dummy-dummy rows. GEMM shape can otherwise + # change the final float32 bit at machine precision. + sample_positions = sample_positions @ rot.T + trans + + sample = _align_graph(sample, row_ind[valid], positions=sample_positions[valid]) + target = _align_graph(target, col_ind[valid]) + return sample, target + + +def partial_optimal_transport_single( + sample_data: ChemAtomicData, + target_data: ChemAtomicData, + c_move: float = 1.0, + c_sub: float = 10.0, + c_ins: float = 5.0, + c_del: float = 10.0, + optimal_transport: str = "equivariant", + pre_align: bool = False, + squared: bool = False, + pre_align_iters: int = 2, +) -> tuple[ChemAtomicData, ChemAtomicData]: + """Single-pair partial optimal transport alignment. + + ``squared`` selects the W2 (squared-Euclidean) ground cost. ``pre_align_iters`` + > 0 runs the equivariant-OT inner loop (alternating Hungarian + Kabsch) for + that many iterations before the final class-aware assignment; it supersedes + the legacy ``pre_align`` boolean (which maps to 3 iterations). + """ + x0 = sample_data.positions.detach().cpu().numpy() + x1 = target_data.positions.detach().cpu().numpy() + a0 = sample_data.atom_types.detach().cpu().numpy() + a1 = target_data.atom_types.detach().cpu().numpy() + + iters = pre_align_iters if pre_align_iters > 0 else (3 if pre_align else 0) + if iters > 0: + x0, x1 = pre_align_positions(x0, x1, num_iterations=iters, squared=squared) + + row_ind, col_ind = distance_and_class_based_assignment( + x0, x1, a0, a1, c_move, c_sub, c_ins, c_del, squared=squared + ) + return _align_and_filter( + sample_data, target_data, row_ind, col_ind, optimal_transport + ) + + +def scaffold_based_assignment_single( + sample_data: ChemAtomicData, + target_data: ChemAtomicData, + scaffold_pairs: list[tuple[int, int]], + c_move: float = 1.0, + c_sub: float = 10.0, + c_ins: float = 5.0, + c_del: float = 10.0, + optimal_transport: str = "equivariant", + squared: bool = False, +) -> tuple[ChemAtomicData, ChemAtomicData]: + """Scaffold-aware partial OT alignment. + + Locks ``scaffold_pairs`` and runs MCS-constrained OT on the remaining atoms. + """ + x0 = sample_data.positions.detach().cpu().numpy() + x1 = target_data.positions.detach().cpu().numpy() + a0 = sample_data.atom_types.detach().cpu().numpy() + a1 = target_data.atom_types.detach().cpu().numpy() + + # Kabsch pre-alignment using the scaffold anchors. + if len(scaffold_pairs) >= 3: + s_idxs = [p[0] for p in scaffold_pairs] + t_idxs = [p[1] for p in scaffold_pairs] + rot, trans = rigid_alignment( + torch.tensor(x0[s_idxs], dtype=torch.float32), + torch.tensor(x1[t_idxs], dtype=torch.float32), + ) + x0 = (torch.tensor(x0, dtype=torch.float32) @ rot.T + trans).numpy() + sample_data = sample_data.clone() + sample_data.positions = torch.tensor( + x0, + dtype=sample_data.positions.dtype, + device=sample_data.positions.device, + ) + + row_ind, col_ind = mcs_constrained_assignment( + x0, + x1, + a0, + a1, + scaffold_pairs, + c_move, + c_sub, + c_ins, + c_del, + squared=squared, + ) + return _align_and_filter( + sample_data, target_data, row_ind, col_ind, optimal_transport + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/integration.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/integration.py new file mode 100644 index 0000000000000000000000000000000000000000..11062d840a5279bf09009980345cfe52305a1c17 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/integration.py @@ -0,0 +1,806 @@ +"""Molecular flow integration with coordinate, substitution, deletion, and insertion + steps. + +States are native ``ChemBatch`` objects. Insertion heads consume flat tensors; +trace records expose the exact decisions made by the production integrator. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch +import torch.nn.functional as F +from scipy.stats import beta as scipy_beta + +from morph.dataset.core.atomic_data import ( + COFRAMED_CONTEXT_KEYS, + ChemBatch, + batch_from_flat, + batch_unpack, + filter_nodes, +) +from morph.dataset.core.insertion_data import ( + InsertedAtoms, + count_new_atom_edges, + insertion_pair_indices, + join_with_atoms, + join_with_predicted_edges, + sample_edge_types, +) +from morph.dataset.core.insertion_targets import ( + BondsBetweenInserted, + BondsToExisting, +) +from morph.flow_matching.sampling_step import ( + InsertionStepTrace, + IntegrationStepTrace, + ScheduleStepMasses, + node_edit_probabilities, +) +from morph.flow_matching.schedules import ( + FastPowerSchedule, + KappaSchedule, + LinearSchedule, +) +from morph.model.layers.gmm import sample_from_typed_gmm +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from collections.abc import Callable + + from morph.dataset.core.vocab import Distributions, Vocab + from morph.model.layers.heads import InsertionEdgeHead + + +@dataclass(frozen=True) +class IntegrationPredictions: + """Clean endpoint and edit predictions, all aligned with the current batch.""" + + mol_1_pred: ChemBatch + do_sub_a_probs: torch.Tensor + do_sub_e_probs: torch.Tensor + do_del_probs: torch.Tensor + num_ins_pred: torch.Tensor + insertion_preds: dict[str, torch.Tensor] + h_latent: torch.Tensor | None = None + + +@dataclass(frozen=True) +class _InsertionContext: + """Original node space and its mapping into the post-deletion batch. + + Bond prediction uses the original positions/features but the substituted + atom types. Counts and batch indices always come from ``mol_t``. + """ + + mol_t: ChemBatch + atom_types: torch.Tensor + spawn_indices: torch.Tensor + time_next: torch.Tensor + predictions: IntegrationPredictions + edge_head: InsertionEdgeHead | None + original_to_postdel: torch.Tensor | None + + +@dataclass(frozen=True) +class _PredictedBondsToExisting: + bonds: BondsToExisting + postdel_existing_idx: torch.Tensor + + +@dataclass(frozen=True) +class _EdgeSubstitution: + edge_types: torch.Tensor + edge_index: torch.Tensor + targets: torch.Tensor + selected: torch.Tensor + + +class RateIntegrator: + """Rate integrator for flow matching on ``ChemBatch`` states.""" + + def __init__( + self, + vocab: Vocab, + distributions: Distributions, + insertion_head_mode: str, + insertion_dimension: int = 3, + insertion_num_components: int = 1, + num_integration_steps=100, + time_strategy="log", + time_beta_alpha: float = 2.0, + time_beta_beta: float = 1.0, + ins_noise_scale=0.25, + renoise_insertions: bool = True, # match unconditional training-time noising + exact_schedule_integration: bool = True, + del_schedule: KappaSchedule | None = None, + ins_schedule: KappaSchedule | None = None, + sub_schedule: KappaSchedule | None = None, + sub_e_schedule: KappaSchedule | None = None, + device="cuda" if torch.cuda.is_available() else "cpu", # noqa: B008 + ): + self.vocab = vocab + self.distributions = distributions + if insertion_head_mode not in {"unimodal", "gmm"}: + raise ValueError( + "insertion_head_mode must be 'unimodal' or 'gmm', got " + f"{insertion_head_mode!r}" + ) + if insertion_num_components < 1: + raise ValueError("insertion_num_components must be positive") + if insertion_head_mode == "unimodal" and insertion_num_components != 1: + raise ValueError("the unimodal insertion head must have one component") + self.insertion_head_mode = insertion_head_mode + self.insertion_dimension = int(insertion_dimension) + self.insertion_num_components = int(insertion_num_components) + cstd = getattr(distributions, "coordinate_std", None) + cstd = float(cstd) if cstd is not None else 1.0 + # Match the interpolator: configuration is in physical Angstroms while + # integration operates in normalized coordinate units. + self.ins_noise_scale = float(ins_noise_scale) / cstd + self.renoise_insertions = renoise_insertions + self.exact_schedule_integration = bool(exact_schedule_integration) + self.time_strategy = time_strategy + self.time_beta_alpha = time_beta_alpha + self.time_beta_beta = time_beta_beta + self.num_integration_steps = num_integration_steps + self.device = device + + self.del_schedule = del_schedule or FastPowerSchedule(beta=2.5) + self.ins_schedule = ins_schedule or FastPowerSchedule(beta=2.5) + self.sub_schedule = sub_schedule or LinearSchedule() + self.sub_e_schedule = sub_e_schedule or self.sub_schedule + + n_atoms_distribution = self.distributions.n_atoms_distribution + self.max_atoms = len(n_atoms_distribution) + 10 # buffer for ins / del + + self._cat_atom = torch.distributions.Categorical( + probs=distributions.atom_type_distribution.to(device) + ) + self._cat_edge = torch.distributions.Categorical( + probs=distributions.edge_type_distribution.to(device) + ) + self._cat_device = ( + torch.device(device) if not isinstance(device, torch.device) else device + ) + + def schedule_step_masses( + self, t: torch.Tensor, dt: float | torch.Tensor, *, is_final: bool = False + ) -> ScheduleStepMasses: + """Parameterise all categorical edit probabilities for a time interval. + + Keeping the time discretization here ensures every sampling path uses + the same exact or Euler schedule convention. Exact mode converts the + integrated schedule hazard into the conditional probability that a + still-pending endpoint transition occurs during this interval. + + The sampler's final interval always completes the discrete bridge: + its schedule masses are one, leaving the learned edit + probabilities and masks in control. In particular, stopping the log + grid at 0.999 must not leave predicted endpoint edits pending. + ``is_final`` is step context supplied by the sampling loop, not a + configuration switch; it avoids guessing from rounded time values. + """ + if is_final: + mass = torch.ones_like(t) + return ScheduleStepMasses(mass, mass, mass, mass) + + def step(schedule: KappaSchedule) -> torch.Tensor: + if self.exact_schedule_integration: + return -torch.expm1(-schedule.integrated_rate(t, dt)) + return schedule.rate(t) * dt + + return ScheduleStepMasses( + substitution=step(self.sub_schedule), + edge_substitution=step(self.sub_e_schedule), + insertion=step(self.ins_schedule), + deletion=step(self.del_schedule), + ) + + def _distr_to_device(self, device: torch.device): + device = ( + torch.device(device) if not isinstance(device, torch.device) else device + ) + if getattr(self, "_cat_device", None) == device: + return + self._cat_atom = torch.distributions.Categorical( + probs=self._cat_atom.probs.to(device) + ) + self._cat_edge = torch.distributions.Categorical( + probs=self._cat_edge.probs.to(device) + ) + self._cat_device = device + + def get_time_steps(self, num_steps: int | None = None) -> list[float]: + """Return the per-step time deltas for the configured time strategy.""" + if num_steps is None: + num_steps = self.num_integration_steps + + if self.time_strategy == "linear": + time_points = torch.linspace(0, 1, num_steps + 1).tolist() + elif self.time_strategy == "log": + # ``num_steps + 1`` grid points produce exactly ``num_steps`` + # integration deltas. Stop at 0.999 so rates containing 1 / (1-t) + # are never evaluated at their singular endpoint. + time_points = (1 - torch.logspace(0, -3, num_steps + 1)).tolist() + elif self.time_strategy == "beta": + # Place points via the Beta(alpha, beta) inverse CDF of a uniform + # grid, so the step density follows the same Beta law used to sample + # training time (the ``beta_sched`` in the model config). + u = torch.linspace(0, 1, num_steps + 1).numpy() + ppf = scipy_beta.ppf(u, self.time_beta_alpha, self.time_beta_beta) + time_points = torch.from_numpy(ppf).clamp(0.0, 1.0).tolist() + else: + raise ValueError(f"Invalid time strategy: {self.time_strategy}") + + return [t1 - t0 for t0, t1 in itertools.pairwise(time_points)] + + def sample_insertions(self, insertion_pred: dict, t: torch.Tensor) -> InsertedAtoms: + """Sample one new atom per provided head output (works with batch ids).""" + dimension = self.insertion_dimension + + if self.insertion_head_mode == "unimodal": + # Deterministic mean position; the integrator's insertion renoising + # supplies the spatial spread (mirrors the GMM path's renoising). + mu = insertion_pred["mu"] + sampled_a = torch.multinomial( + torch.softmax(insertion_pred["a_logits"].float(), dim=-1), 1 + ).squeeze(1) + sampled_c = torch.multinomial( + torch.softmax(insertion_pred["c_logits"].float(), dim=-1), 1 + ).squeeze(1) + return InsertedAtoms( + x=mu.view(-1, dimension), + a=sampled_a.view(-1), + c=sampled_c.view(-1), + ) + + num_ins = 1 + num_components = self.insertion_num_components + A = len(self.vocab.atom_tokens) + C = len(self.vocab.charge_tokens) + + sampled_x, sampled_a, sampled_c = sample_from_typed_gmm( + insertion_pred, num_ins, num_components, dimension, A, C + ) + return InsertedAtoms( + x=sampled_x.view(-1, dimension), + a=sampled_a.view(-1), + c=sampled_c.view(-1), + ) + + def _renoise_insertions( + self, new_atoms: InsertedAtoms, t_ins: torch.Tensor + ) -> InsertedAtoms: + """Push freshly-inserted nodes to the t+dt marginal (matches training).""" + if t_ins.numel() == 0: + return new_atoms + + t_ins = t_ins.to(new_atoms.x.device).clamp(0.0, 1.0) + + noise_x = torch.randn_like(new_atoms.x) + scale_x = ((1.0 - t_ins) * self.ins_noise_scale).unsqueeze(-1) + new_atoms.x = new_atoms.x + scale_x * noise_x + + # Atom types follow the same categorical bridge as training. Charge is + # only a clean prediction target, not an interpolated model state. + kappa = self.sub_schedule.kappa_t(t_ins).clamp(0.0, 1.0).unsqueeze(-1) + new_atoms.a = self._kappa_mix_sample(new_atoms.a, self._cat_atom, kappa) + return new_atoms + + def _kappa_mix_sample(self, clean_idx, prior_cat, kappa): + """Resample class indices as a kappa-mix of the clean one-hot and a prior. + + Pushes a freshly predicted (clean) categorical sample toward the t+dt + marginal: mix the clean one-hot with ``prior_cat``'s probabilities, + weighted per-row by ``kappa`` (shape ``[N, 1]``), renormalize in fp32 to + stay on the simplex, and resample. Shared by the atom / charge / edge + renoising paths. + """ + num_classes = prior_cat.probs.shape[-1] + prior = prior_cat.probs.unsqueeze(0).expand(clean_idx.shape[0], -1) + one_hot = F.one_hot(clean_idx, num_classes=num_classes).to(prior.dtype) + # Clamp so the mix is always a convex combination: a schedule that returns + # kappa marginally above 1 (fp32 cancellation near t=1) would otherwise make + # ``prior * (1 - kappa)`` negative, which aborts the process inside + # ``torch.multinomial`` rather than raising. + kappa = ( + kappa.clamp(0.0, 1.0) + if torch.is_tensor(kappa) + else min(max(kappa, 0.0), 1.0) + ) + mix = prior * (1.0 - kappa) + one_hot * kappa + mix = mix.float() + mix = mix / mix.sum(dim=-1, keepdim=True).clamp_min(1e-12) + # torch.multinomial is exactly what Categorical(probs=).sample() calls + # under the hood (same RNG draw) without building the distribution object. + return torch.multinomial(mix, 1).squeeze(1) + + def _renoise_edge_types(self, clean_idx, t_per_edge): + """Push clean inserted-edge types to the t+dt marginal (no-op if disabled).""" + if not self.renoise_insertions: + return clean_idx + kappa = self.sub_e_schedule.kappa_t(t_per_edge).unsqueeze(1) + return self._kappa_mix_sample(clean_idx, self._cat_edge, kappa) + + def _select_spawning_nodes(self, mol_t, num_ins_pred, ins_step_node): + """Sample insertion events and enforce the per-graph atom limit.""" + batch_id = mol_t.batch + # ``num_ins_pred`` is the expected number of pending endpoint + # insertions. The schedule mass is the per-endpoint transition + # probability for this interval, not a hazard to exponentiate jointly + # with the model prediction. At most one insertion per spawning node is + # supported in a numerical step, matching the historical sampler. + p_ins = (num_ins_pred * ins_step_node).clamp(0.0, 1.0) + do_ins = torch.rand_like(p_ins) < p_ins + + spawn_orig_indices = torch.where(do_ins)[0] + has_insertions = spawn_orig_indices.numel() > 0 + if has_insertions: + n_atoms_per_graph = mol_t.num_nodes_per_graph + n_ins_per_graph = torch.zeros_like(n_atoms_per_graph).scatter_add_( + 0, batch_id, do_ins.long() + ) + overflow = (n_atoms_per_graph + n_ins_per_graph - self.max_atoms).clamp( + min=0 + ) + overflow_graphs = torch.nonzero(overflow, as_tuple=False).flatten().tolist() + for g in overflow_graphs: + n_to_remove = int(overflow[g].item()) + removed_indices = torch.where((batch_id == g) & do_ins)[0] + removed_indices = removed_indices[ + torch.randperm(removed_indices.shape[0], device=do_ins.device) + ] + do_ins[removed_indices[:n_to_remove]] = False + if overflow_graphs: + spawn_orig_indices = torch.where(do_ins)[0] + + return do_ins, spawn_orig_indices + + @staticmethod + def _sample_node_edits(predictions, sub_step_node, del_step_node): + """Sample mutually exclusive substitution and deletion events.""" + # The sigmoid heads estimate mutually-exclusive endpoint-edit + # posteriors. Multiply each posterior by its schedule transition mass, + # then add the resulting numerical-step probabilities. + sub_step_mass = predictions.do_sub_a_probs.view(-1) * sub_step_node + del_step_mass = predictions.do_del_probs.view(-1) * del_step_node + node_edit = node_edit_probabilities(sub_step_mass, del_step_mass) + do_edit = torch.rand_like(node_edit.any_edit) < node_edit.any_edit + is_deletion_type = ( + torch.rand_like(node_edit.deletion_given_edit) + < node_edit.deletion_given_edit + ) + do_del = do_edit & is_deletion_type + do_sub_a = do_edit & (~is_deletion_type) + return do_sub_a, do_del + + @staticmethod + def _substitute_edges(mol_t, predictions, step_masses): + """Sample each undirected bond once and restore directed edge order.""" + edge_index, batch_id = mol_t.edge_index, mol_t.batch + e_t, e_1 = mol_t.e, predictions.mol_1_pred.e + # ``mol_1_pred`` is built on ``mol_t.edge_index`` by the sampling loop, + # so all edge tensors already share an order. Avoid sorting them twice. + triu_positions, full_to_triu = batched_triu_edge_layout( + edge_index, batch_id, mol_t.num_nodes_per_graph + ) + edge_index_triu = edge_index[:, triu_positions] + e_triu = e_t[triu_positions] + do_sub_e_probs_triu = predictions.do_sub_e_probs[triu_positions] + e_1_triu = e_1[triu_positions] + + q_sub_e = do_sub_e_probs_triu.view(-1) + batch_id_edge = batch_id[edge_index_triu[0]] + sub_e_step_triu = step_masses.edge_substitution[batch_id_edge] + p_mod_e = (q_sub_e * sub_e_step_triu).view(-1) + do_sub_e = torch.rand_like(p_mod_e) < p_mod_e + e_triu = torch.where(do_sub_e, e_1_triu, e_triu) + + # Mirror the changed per-pair values into the existing directed order. + # The layout mapping remains valid after insertion/deletion reorders the + # edge storage, avoiding a sort without assuming lexicographic order. + e_t = e_triu[full_to_triu] + + return _EdgeSubstitution(e_t, edge_index_triu, e_1_triu, do_sub_e) + + @staticmethod + def _constrain_deletions(mol_t, do_del): + """Restore enough deleted nodes to retain at least two per graph. + + Update ``do_del`` in place so traces describe the edits actually applied. + """ + batch_id = mol_t.batch + node_keep = ~do_del + + # Fail-safe: never delete a whole sample (keep >= 2 nodes per graph). + n_kept_per_graph = torch.zeros_like(mol_t.num_nodes_per_graph).scatter_add_( + 0, batch_id, node_keep.long() + ) + under = (2 - n_kept_per_graph).clamp(min=0) + for g in torch.nonzero(under, as_tuple=False).flatten().tolist(): + n_to_restore = int(under[g].item()) + deleted_in_graph_idx = torch.where((batch_id == g) & do_del)[0] + n_restore = min(n_to_restore, deleted_in_graph_idx.shape[0]) + if n_restore > 0: + restored = deleted_in_graph_idx[:n_restore] + node_keep[restored] = True + do_del[restored] = False + + return node_keep + + def integrate_step_gnn( + self, + mol_t: ChemBatch, + predictions: IntegrationPredictions, + t: torch.Tensor, + dt: float | torch.Tensor, + *, + eps: float = 1e-6, + ins_edge_head: InsertionEdgeHead | None = None, + force_charge_idx: int | None = None, + return_step_trace: bool = False, + position_update: Callable[ + [torch.Tensor, torch.Tensor, torch.Tensor, float | torch.Tensor], + torch.Tensor, + ] + | None = None, + is_final: bool = False, + ) -> ChemBatch | tuple[ChemBatch, IntegrationStepTrace]: + """Integrate one step, optionally returning its realized edit trace. + + By default positions use the standard Euler update. ``position_update`` + optionally supplies an alternative coordinate kernel while retaining the + same topology transition. The sampling loop supplies ``is_final`` to + complete discrete transitions on its final iteration. + """ + active_device = mol_t["positions"].device + self._distr_to_device(active_device) + + x_t, a_t, _, _, edge_index, batch_id = batch_unpack(mol_t) + x_1, a_1, c_1, _, _, _ = batch_unpack(predictions.mol_1_pred) + num_graphs = t.shape[0] + + # A scalar keeps the historical fixed-grid behavior. A (B,) tensor + # allows each graph to advance by a different amount without changing + # any of the node/edge kernels below. + if torch.is_tensor(dt): + dt_graph = dt.to(device=active_device, dtype=t.dtype).reshape(-1) + if dt_graph.numel() != num_graphs: + raise ValueError( + f"tensor dt must have one value per graph ({num_graphs}), " + f"got shape {tuple(dt.shape)}" + ) + dt_position = dt_graph[batch_id].unsqueeze(-1) + else: + dt_graph = dt + dt_position = dt + + conditioning_keep_mask = ( + mol_t["keep_mask"] if "keep_mask" in mol_t else None # noqa: SIM401 + ) + coframed_context = tuple( + (key, context) + for key in COFRAMED_CONTEXT_KEYS + if (context := getattr(mol_t, key, None)) is not None + ) + + _carry_force_uncond = getattr(mol_t, "force_uncond", None) + # Per-graph de-novo flag; drives the null-token-only masking in + # ``_prepend_mode_token``. Carried so the mask persists past step 0 — + # it is (B,) over graphs, which insertion/deletion never changes. + _carry_cond_mask = getattr(mol_t, "cond_mask", None) + + move_rate = 1 / (1 - t).clamp(min=eps, max=1.0 - eps) + move_rate_node = move_rate[batch_id] + + step_masses = self.schedule_step_masses(t, dt_graph, is_final=is_final) + sub_step_node = step_masses.substitution[batch_id] + ins_step_node = step_masses.insertion[batch_id] + del_step_node = step_masses.deletion[batch_id] + + # 1. Positions (deterministic Euler update). ``move_noise_scale`` in the + # interpolator is training-time denoising jitter, not an SDE diffusion + # coefficient, so no new Gaussian movement noise is injected here. + x_t_original = x_t + if position_update is None: + velocity = (x_1 - x_t) * move_rate_node.view(-1, 1) + x_t = x_t + velocity * dt_position + else: + x_t = position_update(x_t, x_1, t[batch_id], dt_position) + if x_t.shape != x_t_original.shape: + raise ValueError( + "position_update must preserve the position tensor shape; " + f"expected {tuple(x_t_original.shape)}, got {tuple(x_t.shape)}" + ) + + do_ins, spawn_orig_indices = self._select_spawning_nodes( + mol_t, predictions.num_ins_pred, ins_step_node + ) + has_insertions = spawn_orig_indices.numel() > 0 + + do_sub_a, do_del = self._sample_node_edits( + predictions, sub_step_node, del_step_node + ) + a_t = torch.where(do_sub_a, a_1, a_t) + + edge_edit = self._substitute_edges(mol_t, predictions, step_masses) + + mol = batch_from_flat( + x_t, + a_t, + c_1, + edge_edit.edge_types, + edge_index, + batch_id, + num_graphs=num_graphs, + node_extras=( + {"keep_mask": conditioning_keep_mask} + if conditioning_keep_mask is not None + else None + ), + system_extras={key: mol_t[key] for key in mol_t.keys["system"]}, + _already_sorted=True, + _node_counts=mol_t.num_nodes_per_graph, + _edge_counts=mol_t.num_edges_per_graph, + ) + + node_keep = self._constrain_deletions(mol_t, do_del) + + num_original = mol.num_nodes + has_deletions = bool(do_del.any()) + if has_deletions: + mol = filter_nodes(mol, node_keep) + + insertion_trace = None + if has_insertions: + original_to_postdel = None + if ins_edge_head is not None and predictions.h_latent is not None: + if has_deletions: + original_to_postdel = torch.full( + (num_original,), + -1, + dtype=torch.long, + device=active_device, + ) + kept_indices = torch.where(node_keep)[0] + original_to_postdel[kept_indices] = torch.arange( + kept_indices.numel(), device=active_device + ) + else: + original_to_postdel = torch.arange( + num_original, device=active_device + ) + mol, insertion_trace = self._apply_insertions( + mol=mol, + context=_InsertionContext( + mol_t=mol_t, + atom_types=a_t, + spawn_indices=spawn_orig_indices, + time_next=t + dt_graph, + predictions=predictions, + edge_head=ins_edge_head, + original_to_postdel=original_to_postdel, + ), + force_charge_idx=force_charge_idx, + return_step_trace=return_step_trace, + ) + + if _carry_force_uncond is not None: + object.__setattr__(mol, "force_uncond", _carry_force_uncond) + if _carry_cond_mask is not None: + object.__setattr__(mol, "cond_mask", _carry_cond_mask) + for key, context in coframed_context: + object.__setattr__(mol, key, context) + if not return_step_trace: + return mol + return mol, IntegrationStepTrace( + positions=x_t, + atom_type_targets=a_1, + charge_targets=c_1, + node_substitutions=do_sub_a, + node_deletions=do_del, + edge_index=edge_edit.edge_index, + edge_type_targets=edge_edit.targets, + edge_substitutions=edge_edit.selected, + insertions=do_ins, + insertion=insertion_trace, + ) + + def _apply_insertions( + self, + mol: ChemBatch, + context: _InsertionContext, + *, + force_charge_idx: int | None = None, + return_step_trace: bool = False, + ) -> tuple[ChemBatch, InsertionStepTrace | None]: + """Sample inserted atoms and join them (predicted edges if a head is given).""" + if self.insertion_head_mode == "unimodal": + ins_head_keys = ("mu", "a_logits", "c_logits") + else: + ins_head_keys = ("mu", "sigma", "pi", "a_probs", "c_probs") + active_insertion_pred = { + key: context.predictions.insertion_preds[key][context.spawn_indices] + for key in ins_head_keys + } + ins_batch_id = context.mol_t.batch[context.spawn_indices] + t_ins = context.time_next[ins_batch_id] + clean_atoms = self.sample_insertions(active_insertion_pred, t_ins) + if force_charge_idx is not None: + clean_atoms.c = torch.full_like(clean_atoms.c, force_charge_idx) + clean_atoms.batch = ins_batch_id + + # Predict clean bonds from the clean insertion sample, but insert a copy at the + # current noise level so the next model step sees the expected state. + new_atoms = InsertedAtoms( + clean_atoms.x.clone(), + clean_atoms.a.clone(), + clean_atoms.c.clone(), + clean_atoms.batch, + ) + if self.renoise_insertions: + new_atoms = self._renoise_insertions(new_atoms, t_ins) + + edge_dist = self._cat_edge.probs + + new_atoms_batch = batch_from_flat( + new_atoms.x, + new_atoms.a, + new_atoms.c, + None, + None, + new_atoms.batch, + num_graphs=context.mol_t.num_graphs, + _already_sorted=True, + ) + + bonds_to_existing = None + bonds_between_inserted = None + + if context.edge_head is not None and context.predictions.h_latent is not None: + bonds_to_existing = self._predict_inserted_to_existing_edges( + context, clean_atoms + ) + bonds_between_inserted = self._predict_inserted_to_inserted_edges( + context, clean_atoms + ) + + use_predicted_edges = bonds_to_existing is not None + if use_predicted_edges: + result = join_with_predicted_edges( + mol, + new_atoms_batch, + e_ins=bonds_to_existing.bonds.bond_type, + spawn_node_idx=bonds_to_existing.bonds.inserted_idx, + target_node_idx=bonds_to_existing.postdel_existing_idx, + e_ins_to_ins=( + bonds_between_inserted.bond_type + if bonds_between_inserted is not None + else None + ), + ins_to_ins_src_idx=( + bonds_between_inserted.src_idx + if bonds_between_inserted is not None + else None + ), + ins_to_ins_dst_idx=( + bonds_between_inserted.dst_idx + if bonds_between_inserted is not None + else None + ), + sample_fallback_edge_types=lambda n: sample_edge_types(edge_dist, n), + ) + else: + # Fall back to random edge sampling (outsourced). + n_edges = count_new_atom_edges(mol, new_atoms_batch) + result = join_with_atoms( + mol, new_atoms_batch, sample_edge_types(edge_dist, n_edges) + ) + + if not return_step_trace: + return result, None + return result, InsertionStepTrace( + spawn_node_idx=context.spawn_indices, + clean_atoms=clean_atoms, + bonds_to_existing=( + bonds_to_existing.bonds if bonds_to_existing is not None else None + ), + bonds_between_inserted=( + bonds_between_inserted if use_predicted_edges else None + ), + ) + + def _predict_inserted_to_existing_edges( + self, + context: _InsertionContext, + new_atoms: InsertedAtoms, + ) -> _PredictedBondsToExisting | None: + """Predict (inserted -> existing) bonds -> (new_atom_idx, target_idx, types).""" + mol_t = context.mol_t + orig_spawn_idx, orig_existing_idx, ins_edge_logits = ( + context.edge_head.predict_inserted_to_existing( + h=context.predictions.h_latent, + x=mol_t.x, + node_atom_types=context.atom_types, + batch=mol_t.batch, + insertion_indices=context.spawn_indices, + node_counts=mol_t.num_nodes_per_graph, + ins=InsertedAtoms(new_atoms.x, new_atoms.a, new_atoms.c), + ) + ) + + if ins_edge_logits is None or ins_edge_logits.numel() == 0: + return None + + # Promote logits before normalization to avoid bf16 probability loss. + ins_edge_probs = F.softmax(ins_edge_logits.float(), dim=-1) + e1_ins_idx = torch.multinomial(ins_edge_probs, 1).squeeze(1) + spawn_batch_id = mol_t.batch[orig_spawn_idx] + e_t_ins = self._renoise_edge_types( + e1_ins_idx, + context.time_next[spawn_batch_id], + ) + + orig_to_new_atom = torch.full( + (mol_t.num_nodes,), -1, dtype=torch.long, device=mol_t.x.device + ) + orig_to_new_atom[context.spawn_indices] = torch.arange( + context.spawn_indices.numel(), device=mol_t.x.device + ) + ins_edge_new_atom_idx = orig_to_new_atom[orig_spawn_idx] + ins_edge_target_idx = context.original_to_postdel[orig_existing_idx] + # The deletion fail-safe leaves every insertion's graph with at least + # one existing atom, so at least one endpoint is always valid. + valid_edges = torch.where(ins_edge_target_idx >= 0)[0] + + return _PredictedBondsToExisting( + bonds=BondsToExisting( + spawn_idx=orig_spawn_idx[valid_edges], + existing_idx=orig_existing_idx[valid_edges], + inserted_idx=ins_edge_new_atom_idx[valid_edges], + bond_type=e_t_ins[valid_edges], + ), + postdel_existing_idx=ins_edge_target_idx[valid_edges], + ) + + def _predict_inserted_to_inserted_edges( + self, + context: _InsertionContext, + new_atoms: InsertedAtoms, + ) -> BondsBetweenInserted | None: + """Predict (inserted -> inserted) bonds for intra-graph upper-tri pairs.""" + ii_src, ii_dst, ii_spawn_src, ii_spawn_dst = insertion_pair_indices( + new_atoms.batch, context.spawn_indices + ) + if ii_src.numel() == 0: + return None + + new_feats = InsertedAtoms(new_atoms.x, new_atoms.a, new_atoms.c) + ii_logits = context.edge_head.forward_inserted_to_inserted( + h=context.predictions.h_latent, + x=context.mol_t.x, + spawn_src_idx=ii_spawn_src, + spawn_dst_idx=ii_spawn_dst, + src=new_feats[ii_src], + dst=new_feats[ii_dst], + ) + # ins->ins passes logits directly (avoids a bf16 softmax precision loss). + e1_ii_idx = torch.multinomial( + torch.softmax(ii_logits.float(), dim=-1), 1 + ).squeeze(1) + ii_batch_id = context.mol_t.batch[ii_spawn_src] + e_t_ins_ii = self._renoise_edge_types( + e1_ii_idx, + context.time_next[ii_batch_id], + ) + return BondsBetweenInserted( + spawn_src_idx=ii_spawn_src, + spawn_dst_idx=ii_spawn_dst, + src_idx=ii_src, + dst_idx=ii_dst, + bond_type=e_t_ins_ii, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/interpolation.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/interpolation.py new file mode 100644 index 0000000000000000000000000000000000000000..3bb54f3c87bbd70c36890964cae2145ae9849158 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/interpolation.py @@ -0,0 +1,546 @@ +"""Flow-matching interpolation for Morph geometric graphs. + +Rate targets are registered as node-, edge-, or graph-level extras on +``ChemAtomicData``. Ragged insertion supervision lives in the dedicated +``InsertionTargets`` dataclass. + +The interpolation maths follows the original implementation, except that charge +is intentionally a clean prediction target rather than a dynamic state. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.dataset.core.insertion_targets import ( + BondsBetweenInserted, + BondsToExisting, + InsertionTargets, +) +from morph.flow_matching.assignment import ( + partial_optimal_transport_single, + scaffold_based_assignment_single, +) +from morph.flow_matching.scaffold_insertion import scaffold_insertion_candidates +from morph.flow_matching.schedules import ( + FastPowerSchedule, + KappaSchedule, + LinearSchedule, +) +from morph.utils.graph_math import complete_graph_layout + +if TYPE_CHECKING: + from morph.dataset.core.vocab import Distributions + + +def existing_graph_layout(mask_exists, full_edge_index): + """Return the node map, retained complete edges, and source-edge selection.""" + keep = mask_exists.nonzero(as_tuple=True)[0] + node_map = torch.full( + (mask_exists.numel(),), -1, dtype=torch.long, device=mask_exists.device + ) + node_map[keep] = torch.arange(keep.numel(), device=mask_exists.device) + remapped = node_map[full_edge_index] + selected = (remapped[0] >= 0) & (remapped[1] >= 0) + return node_map, remapped[:, selected].t().contiguous(), selected + + +def _nearest_insertion_per_node(spawn_node_idx, spawn_dist, num_nodes): + """Invert the one-to-many ``insertion -> nearest node`` map into a one-to-one + ``node -> nearest insertion`` assignment. + + For every existing node that is the nearest node of at least one insertion, + return ``(node_idx, ins_idx)`` where ``ins_idx`` is that node's *single + closest* assigned insertion. Each node id appears at most once. + + Args: + spawn_node_idx: ``[N_ins]`` nearest existing node per inserted atom. + spawn_dist: ``[N_ins]`` distance of each insertion to its nearest node. + num_nodes: number of existing nodes (size of the scatter target). + """ + if spawn_node_idx.numel() == 0: + empty = spawn_node_idx.new_empty((0,)) + return empty, empty.clone() + + # Order insertions by ascending distance; the earliest occurrence of a node + # id in that order is its nearest insertion. A stable sort makes ties (which + # are measure-zero for continuous coordinates anyway) deterministic. + order = torch.argsort(spawn_dist, stable=True) + nodes_in_order = spawn_node_idx[order] + ranks = torch.arange(nodes_in_order.numel(), device=spawn_node_idx.device) + + sentinel = nodes_in_order.numel() + earliest = torch.full( + (num_nodes,), sentinel, dtype=torch.long, device=spawn_node_idx.device + ) + earliest.scatter_reduce_(0, nodes_in_order, ranks, reduce="amin", include_self=True) + + has_ins = earliest < sentinel + nearest_node_idx = torch.nonzero(has_ins, as_tuple=False).squeeze(1) + nearest_ins_idx = order[earliest[nearest_node_idx]] + return nearest_node_idx, nearest_ins_idx + + +class Interpolator: + """Flow-matching interpolator operating on :class:`ChemAtomicData`.""" + + def __init__( + self, + distributions: Distributions, + optimal_transport="equivariant", + move_noise_scale=0.2, + ins_noise_scale=0.25, + ins_schedule: KappaSchedule | None = None, + del_schedule: KappaSchedule | None = None, + sub_schedule: KappaSchedule | None = None, + sub_e_schedule: KappaSchedule | None = None, + c_move=1.0, + c_sub=0.0, + c_ins=1e6, + c_del=1e6, + pre_align_iters: int = 2, + atom_type_noise_prob: float = 0.0, + noise_distributions: Distributions | None = None, + jitter_scale: float = 0.1, + insertion_assignment: str = "nearest", + ): + if insertion_assignment not in {"nearest", "substituent"}: + raise ValueError("insertion_assignment must be 'nearest' or 'substituent'") + self.insertion_assignment = insertion_assignment + self.optimal_transport = optimal_transport + self.atom_type_noise_prob = float(atom_type_noise_prob) + if not 0.0 <= self.atom_type_noise_prob <= 1.0: + raise ValueError("atom_type_noise_prob must be between zero and one") + # Corruption uses empirical frequencies, independently of the uniform + # source prior. Standalone callers may use their supplied distributions. + noise_distributions = noise_distributions or distributions + self._noise_atom_probs = noise_distributions.atom_type_distribution + self._noise_edge_probs = noise_distributions.edge_type_distribution + self._noise_has_edges = bool(distributions.edge_type_distribution[1:].any()) + self.del_schedule = del_schedule or FastPowerSchedule(beta=2.5) + self.ins_schedule = ins_schedule or FastPowerSchedule(beta=2.5) + self.sub_schedule = sub_schedule or LinearSchedule() + self.sub_e_schedule = sub_e_schedule or self.sub_schedule + + # OT matching. The ground cost is *always* the squared-Euclidean (W2) + # distance, so the permutation, the Kabsch alignment and the FM path all + # minimize the same energy. The move cost is counted in ANGSTROM: c_move + # multiplies the squared move distance in A^2. Positions are stored + # normalized (÷ coordinate_std), so we fold coordinate_std^2 into c_move + # here; c_ins / c_del / c_sub are then plain A^2 costs, directly + # comparable and set straight from the config. The defaults + # (c_ins=c_del=1e6, c_sub=0) heavily penalize insertion/deletion, so + # overlapping atoms are always *moved* rather than del+ins'd. + # pre_align_iters runs the equivariant-OT inner loop (Hungarian+Kabsch) + # before the class-aware assignment. + self.pre_align_iters = int(pre_align_iters) + + cstd = getattr(distributions, "coordinate_std", None) + cstd = float(cstd) if cstd is not None else 1.0 + # Public noise scales are physical Angstroms while coordinates are stored + # normalized by ``coordinate_std``. + self.move_noise_scale = float(move_noise_scale) / cstd + self.ins_noise_scale = float(ins_noise_scale) / cstd + self.jitter_scale = float(jitter_scale) / cstd + self.c_move = c_move * cstd * cstd # squared: normalized -> A^2 move cost + self.c_sub = c_sub + self.c_ins = c_ins + self.c_del = c_del + + self._cat_atom = torch.distributions.Categorical( + probs=distributions.atom_type_distribution + ) + self._cat_charge = torch.distributions.Categorical( + probs=distributions.charge_type_distribution + ) + self._cat_edge = torch.distributions.Categorical( + probs=distributions.edge_type_distribution + ) + + # -- elementary interpolation (identical to the original) ----------------- + def interpolate_continuous(self, x0, x1, t): + """Linear interpolation between continuous tensors at time ``t``.""" + return x0 * (1 - t) + x1 * t + + def interpolate_discrete(self, y0_idx, y1_idx, t): + """Bernoulli interpolation of class indices: keep ``y0`` w.p. ``1 - t``.""" + keep_source = torch.rand(y0_idx.shape[0], device=t.device) > t + return torch.where(keep_source, y0_idx, y1_idx) + + @staticmethod + def _corrupt_categorical(values, eligible, probability, base_probs): + """Redraw selected tokens from empirical alternatives, excluding self.""" + if probability <= 0.0 or values.numel() == 0 or base_probs.numel() <= 1: + return values + changed = eligible & ( + torch.rand(values.shape, device=values.device) < probability + ) + current = values[changed].long() + if current.numel() == 0: + return values + probs = base_probs.to(device=values.device, dtype=torch.float32) + probs = probs.expand(current.shape[0], -1).clone() + probs.scatter_(1, current.unsqueeze(-1), 0.0) + # A degenerate empirical categorical can have no alternative mass. + # Fall back to uniform alternatives in those rows only. + no_alternative = probs.sum(dim=-1) <= 0.0 + probs[no_alternative] = 1.0 + probs.scatter_(1, current.unsqueeze(-1), 0.0) + corrupted = values.clone() + corrupted[changed] = torch.multinomial(probs, 1).squeeze(-1).to(values.dtype) + return corrupted + + def _balanced_edge_noise_probability(self, num_atoms, num_pairs): + """Match the expected number of changed pairs to changed atom tokens.""" + if num_pairs == 0 or not self._noise_has_edges: + return 0.0 + return min(self.atom_type_noise_prob * num_atoms / num_pairs, 1.0) + + @staticmethod + def _extract_triu_feats(edge_index, e, n, triu_rows): + if e is None or e.numel() == 0: + return triu_rows.new_zeros(triu_rows.shape) + out = e.new_zeros((triu_rows.shape[0],)) + row, col = edge_index[0], edge_index[1] + upper = row < col + i, j = row[upper], col[upper] + pos = (i * (2 * n - i - 1)) // 2 + (j - i - 1) + out[pos] = e[upper] + return out + + def interpolate_single( + self, + sample_data: ChemAtomicData, + target_data: ChemAtomicData, + t_scalar: float, + scaffold_pairs: list[tuple[int, int]] | None = None, + existing_noise: str = "move", + ) -> tuple[ChemAtomicData, ChemAtomicData, InsertionTargets]: + """Interpolate a training pair, including role-specific coordinate noise. + + Scaffold training uses ``existing_noise="jitter"``: matched atoms get + small denoising jitter, while insertions get only their (1-t) bridge + noise. This training interpolator is never used for inference inputs. + """ + if existing_noise not in {"move", "jitter"}: + raise ValueError("existing_noise must be 'move' or 'jitter'") + if ( + self.insertion_assignment == "substituent" + and scaffold_pairs + and len(scaffold_pairs) != sample_data.num_nodes + ): + raise ValueError( + "Substituent insertion assignment requires a bare scaffold" + ) + device = sample_data.positions.device + t_i = torch.tensor(t_scalar, device=device) + + # ===== 1. OT alignment ===== + if scaffold_pairs: + sample, target = scaffold_based_assignment_single( + sample_data, + target_data, + scaffold_pairs=scaffold_pairs, + c_move=self.c_move, + c_sub=self.c_sub, + c_ins=self.c_ins, + c_del=self.c_del, + optimal_transport=self.optimal_transport, + squared=True, + ) + else: + sample, target = partial_optimal_transport_single( + sample_data, + target_data, + c_move=self.c_move, + c_sub=self.c_sub, + c_ins=self.c_ins, + c_del=self.c_del, + optimal_transport=self.optimal_transport, + squared=True, + pre_align_iters=self.pre_align_iters, + ) + + n = sample.num_nodes + xs, as_, cs = sample.positions, sample.atom_types, sample.charge_types + xt, at, ct = target.positions, target.atom_types, target.charge_types + is_aux_s = sample.is_auxiliary + is_aux_t = target.is_auxiliary + s_nl, t_nl = sample.neighbor_list, target.neighbor_list + s_edge_index = s_nl.t() if s_nl is not None else None + s_e = sample.edge_types + t_edge_index = t_nl.t() if t_nl is not None else None + t_e = target.edge_types + + # ===== 2. Masks ===== + is_sub = ((~is_aux_s) & (~is_aux_t)).squeeze(-1) + is_del = ((~is_aux_s) & (is_aux_t)).squeeze(-1) + is_ins = ((is_aux_s) & (~is_aux_t)).squeeze(-1) + + t_kappa, inst_rate_sub = self.sub_schedule.kappa_and_rate(t_i) + if self.sub_e_schedule is self.sub_schedule: + t_kappa_e, inst_rate_sub_e = t_kappa, inst_rate_sub + else: + t_kappa_e, inst_rate_sub_e = self.sub_e_schedule.kappa_and_rate(t_i) + + tau_del = torch.rand(n, 1, device=device) + t_del, inst_rate_del = self.del_schedule.kappa_and_rate(t_i) + + tau_ins = torch.rand(n, 1, device=device) + t_ins, inst_rate_ins = self.ins_schedule.kappa_and_rate(t_i) + + mask_keep_sub = is_sub + mask_keep_del = is_del & (t_del < tau_del).squeeze(-1) + mask_keep_ins = is_ins & (t_ins > tau_ins).squeeze(-1) + mask_future_ins = is_ins & (t_ins <= tau_ins).squeeze(-1) + mask_exists = mask_keep_sub | mask_keep_del | mask_keep_ins + + # A. Deletions freeze at source + if mask_keep_del.any(): + xt[mask_keep_del] = xs[mask_keep_del] + at[mask_keep_del] = as_[mask_keep_del] + ct[mask_keep_del] = cs[mask_keep_del] + + # B. Insertions jump to target with noise + if mask_keep_ins.any(): + n_ins = int(mask_keep_ins.sum()) + xs[mask_keep_ins] = ( + xt[mask_keep_ins] + + torch.randn(n_ins, xs.shape[-1], device=device) * self.ins_noise_scale + ) + as_[mask_keep_ins] = self._cat_atom.sample((n_ins,)).to(device) + cs[mask_keep_ins] = self._cat_charge.sample((n_ins,)).to(device) + + # C. Edge handling on the upper triangle over the N aligned nodes + full_edge_index, triu_rows, triu_cols, full_to_triu = complete_graph_layout( + n, device + ) + + e0_triu = self._extract_triu_feats(s_edge_index, s_e, n, triu_rows) + e1_triu = self._extract_triu_feats(t_edge_index, t_e, n, triu_rows) + + edge_has_del = mask_keep_del[triu_rows] | mask_keep_del[triu_cols] + edge_has_ins = mask_keep_ins[triu_rows] | mask_keep_ins[triu_cols] + edge_mask_del = edge_has_del & ~edge_has_ins + edge_mask_ins = edge_has_ins & ~edge_has_del + + if edge_mask_del.any(): + e1_triu[edge_mask_del] = e0_triu[edge_mask_del] + if edge_mask_ins.any(): + n_edges_ins = int(edge_mask_ins.sum()) + rand_edges = self._cat_edge.sample((n_edges_ins,)).to(device) + e0_triu[edge_mask_ins] = rand_edges.to(dtype=e0_triu.dtype) + + # D. Interpolate (positions, then discrete attrs -- order preserved) + x_t = self.interpolate_continuous(xs, xt, t_i) + # Restore the original scaffold path: retained/matched atoms receive + # 0.1 A jitter; insertions have only the bridge noise already in xs. + # Unconditional training retains its move-noise floor on matched atoms + # and present insertions. Deletions and future insertions stay untouched. + if existing_noise == "jitter": + noise_scale, noise_mask = self.jitter_scale, is_sub + else: + noise_scale, noise_mask = self.move_noise_scale, is_sub | mask_keep_ins + + position_noise = torch.randn_like(x_t) * noise_scale + position_noise[~noise_mask] = 0.0 + x_t = x_t + position_noise + a_t = self.interpolate_discrete(as_, at, t_kappa) + # Charge is a clean-state prediction target, not an interpolated state. + # It is not embedded by the backbone; retaining the source value merely + # keeps the atomic container schema complete until the final charge head + # prediction is sampled. + c_t = cs + e_t = self.interpolate_discrete(e0_triu, e1_triu, t_kappa_e) + + if self.atom_type_noise_prob > 0.0: + # Keep the clean endpoint and derive the usual edit labels below + # from the corrupted input. Source-only deletion nodes have frozen + # targets and masked type losses, so do not corrupt them. + eligible_atoms = mask_keep_sub | mask_keep_ins + if scaffold_pairs is not None: + # Scaffold nodes occupy the leading aligned slots; their atom + # types and internal bonds cannot change during generation. + editable_atoms = eligible_atoms.clone() + editable_atoms[: len(scaffold_pairs)] = False + else: + editable_atoms = eligible_atoms + a_t = self._corrupt_categorical( + a_t, editable_atoms, self.atom_type_noise_prob, self._noise_atom_probs + ) + eligible_pairs = eligible_atoms[triu_rows] & eligible_atoms[triu_cols] + eligible_pairs &= editable_atoms[triu_rows] | editable_atoms[triu_cols] + edge_noise_prob = self._balanced_edge_noise_probability( + int(editable_atoms.sum()), int(eligible_pairs.sum()) + ) + e_t = self._corrupt_categorical( + e_t, eligible_pairs, edge_noise_prob, self._noise_edge_probs + ) + + # E. Both states share the cached complete topology. + interp_e = e_t[full_to_triu] + target_e = e1_triu[full_to_triu] + target_full = ChemAtomicData( + positions=xt, + atom_types=at, + charge_types=ct, + neighbor_list=full_edge_index.t().contiguous(), + edge_types=target_e, + ) + + node_map_exists, neighbor_list, edge_exists = existing_graph_layout( + mask_exists, full_edge_index + ) + + interp_state = ChemAtomicData( + positions=x_t[mask_exists], + atom_types=a_t[mask_exists], + charge_types=c_t[mask_exists], + neighbor_list=neighbor_list, + edge_types=interp_e[edge_exists], + is_auxiliary=(is_aux_s | is_aux_t)[mask_exists], + ) + target_state = ChemAtomicData( + positions=xt[mask_exists], + atom_types=at[mask_exists], + charge_types=ct[mask_exists], + neighbor_list=neighbor_list.clone(), + edge_types=target_e[edge_exists], + ) + + # F. Spawn logic + insertion supervision (local indices) + ins_targets = self._build_insertion_targets( + interp_state, + target_full, + mask_future_ins.nonzero(as_tuple=True)[0], + node_map_exists, + n_scaffold=len(scaffold_pairs) if scaffold_pairs else 0, + ) + + # G. Rate targets + ins_counts = torch.bincount( + ins_targets.spawn_node_idx, minlength=interp_state.num_nodes + ).to(dtype=interp_state.positions.dtype) + + node_del_rate_target = torch.zeros((n,), device=device) + node_del_rate_target[mask_keep_del] = inst_rate_del + + interp_state.add_node_property("lambda_ins", ins_counts * inst_rate_ins) + interp_state.add_node_property("n_ins", ins_counts) + interp_state.add_node_property("lambda_del", node_del_rate_target[mask_exists]) + interp_state.add_node_property( + "lambda_a_sub", + (interp_state.atom_types != target_state.atom_types).int() * inst_rate_sub, + ) + interp_state.add_edge_property( + "lambda_e_sub", + (interp_state.edge_types != target_state.edge_types).int() + * inst_rate_sub_e, + ) + interp_state.add_system_property( + "n_ins_missing", mask_future_ins.sum().float().reshape(1) + ) + interp_state.add_system_property( + "n_del_missing", mask_keep_del.sum().float().reshape(1) + ) + + # H. Coordinate centering (ZCoM in the active frame) + x_mean = interp_state.positions.mean(dim=0) + # Fixed contexts bypass interpolation and must receive this translation. + interp_state.frame_shift = x_mean + interp_state.positions = interp_state.positions - x_mean + if ins_targets.num_nodes > 0: + ins_targets.positions = ins_targets.positions - x_mean + target_state.positions = target_state.positions - target_state.positions.mean( + dim=0 + ) + + # Per-sample topology edits remain on the lightweight native container; + # the collate concatenates these tensors directly into a ChemBatch. + return interp_state, target_state, ins_targets + + def _build_insertion_targets( + self, + interp_state, + target_full, + future_indices, + orig_to_interp, + *, + n_scaffold=0, + ) -> InsertionTargets: + """Spawn-node assignment and insertion-bond supervision (local indices).""" + num_interp = interp_state.num_nodes + num_inserted = future_indices.numel() + pos = target_full.positions[future_indices] + atoms = target_full.atom_types[future_indices] + charges = target_full.charge_types[future_indices] + + if num_inserted == 0 or num_interp == 0: + return InsertionTargets.empty(pos, atoms, charges, target_full.device) + + # Use the nearest eligible existing atom. Site constraints apply only + # to scaffold training; unconditional/replay pairs keep the usual rule. + dist_to_interp = torch.cdist(pos, interp_state.positions) + if self.insertion_assignment == "substituent" and n_scaffold: + candidates = scaffold_insertion_candidates( + target_full, n_scaffold, future_indices, orig_to_interp + ) + dist_to_interp = dist_to_interp.masked_fill(~candidates, float("inf")) + spawn_node_idx = dist_to_interp.argmin(dim=1) + # One-to-one inverse: each spawn node's single nearest insertion (used by + # the unimodal insertion head). + spawn_dist = dist_to_interp.gather(1, spawn_node_idx.unsqueeze(1)).squeeze(1) + nearest_node_idx, nearest_ins_idx = _nearest_insertion_per_node( + spawn_node_idx, spawn_dist, num_interp + ) + + orig_to_ins = torch.full_like(orig_to_interp, -1) + orig_to_ins[future_indices] = torch.arange( + num_inserted, device=target_full.device + ) + + # Classify each (directed) target edge by its endpoints. Boolean indexing + # naturally yields empty tensors when a class has no edges, so no special + # casing is needed. + src, dst = target_full.neighbor_list.t() + src_is_inserted = orig_to_ins[src] >= 0 + dst_is_existing = orig_to_interp[dst] >= 0 + dst_is_inserted = orig_to_ins[dst] >= 0 + + # inserted -> existing + to_exist = src_is_inserted & dst_is_existing + inserted_idx = orig_to_ins[src[to_exist]] + bonds_to_existing = BondsToExisting( + spawn_idx=spawn_node_idx[inserted_idx], + existing_idx=orig_to_interp[dst[to_exist]], + inserted_idx=inserted_idx, + bond_type=target_full.edge_types[to_exist], + ) + + # inserted -> inserted, keeping the upper triangle (src_local < dst_local) + between = src_is_inserted & dst_is_inserted + src_idx = orig_to_ins[src[between]] + dst_idx = orig_to_ins[dst[between]] + upper = src_idx < dst_idx + src_idx, dst_idx = src_idx[upper], dst_idx[upper] + bonds_between_inserted = BondsBetweenInserted( + spawn_src_idx=spawn_node_idx[src_idx], + spawn_dst_idx=spawn_node_idx[dst_idx], + src_idx=src_idx, + dst_idx=dst_idx, + bond_type=target_full.edge_types[between][upper], + ) + + return InsertionTargets( + positions=pos, + atom_types=atoms, + charge_types=charges, + spawn_node_idx=spawn_node_idx, + bonds_to_existing=bonds_to_existing, + bonds_between_inserted=bonds_between_inserted, + nearest_node_idx=nearest_node_idx, + nearest_ins_idx=nearest_ins_idx, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/prior.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/prior.py new file mode 100644 index 0000000000000000000000000000000000000000..55d2a0955cd0c40e381c4c55502978a593d06e9c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/prior.py @@ -0,0 +1,74 @@ +"""Prior-graph sampling for Morph's native geometric-graph container. + +The random-draw order matches the original, so the two agree under the same seed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +from torch.distributions import Categorical + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.utils.graph_math import complete_graph_layout + +if TYPE_CHECKING: + from morph.dataset.core.vocab import Distributions + + +# Cache the (fixed) prior categoricals per distributions object with +# validate_args=False: rebuilding + simplex-validating them per __getitem__ cost +# ~6s in the dataloader profile. Construction draws no RNG -> sample stream unchanged. +_PRIOR_CAT_CACHE: dict[int, tuple] = {} + + +def _prior_categoricals(distributions: Distributions) -> tuple: + key = id(distributions) + cats = _PRIOR_CAT_CACHE.get(key) + if cats is None: + cats = tuple( + Categorical(probs=p, validate_args=False) + for p in ( + distributions.atom_type_distribution, + distributions.edge_type_distribution, + distributions.charge_type_distribution, + distributions.n_atoms_distribution, + ) + ) + _PRIOR_CAT_CACHE[key] = cats + return cats + + +def sample_prior_graph( + distributions: Distributions, + n_atoms: int | None = None, +) -> ChemAtomicData: + """Sample a fully-connected prior graph from the training distributions. + + Atom / charge / edge types are drawn from their categorical priors, and + coordinates from a centered standard normal. + """ + p_atom_types, p_edge_types, p_charge_types, p_n_atoms = _prior_categoricals( + distributions + ) + + num_atoms = n_atoms if n_atoms is not None else int(p_n_atoms.sample()) + + atom_types = p_atom_types.sample((num_atoms,)) + charge_types = p_charge_types.sample((num_atoms,)) + + coord = torch.randn(num_atoms, 3) + coord = coord - coord.mean(dim=0) + + edge_index, _, _, triu_index = complete_graph_layout(num_atoms) + num_triu_edges = (num_atoms**2 - num_atoms) // 2 + edge_types = p_edge_types.sample((num_triu_edges,))[triu_index] + + return ChemAtomicData( + positions=coord, + atom_types=atom_types, + charge_types=charge_types, + edge_types=edge_types, + neighbor_list=edge_index.t().contiguous(), + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/sampling_step.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/sampling_step.py new file mode 100644 index 0000000000000000000000000000000000000000..4b246a1bcceb3513f24df93016b92dbda488e5a7 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/sampling_step.py @@ -0,0 +1,104 @@ +"""Shared parameterization and optional trace records for sampling steps. + +The integrator remains the source of transition semantics. Callbacks can inspect +the same deletion, insertion, edge, and schedule decisions without duplicating +the transition implementation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import torch + + from morph.dataset.core.insertion_data import InsertedAtoms + from morph.dataset.core.insertion_targets import ( + BondsBetweenInserted, + BondsToExisting, + ) + + +@dataclass(frozen=True) +class ScheduleStepMasses: + """Integrated categorical jump masses for one graph-level time step.""" + + substitution: torch.Tensor + edge_substitution: torch.Tensor + insertion: torch.Tensor + deletion: torch.Tensor + + +@dataclass(frozen=True) +class NodeEditProbabilities: + """Mutually exclusive numerical-step edit probabilities.""" + + any_edit: torch.Tensor + deletion_given_edit: torch.Tensor + + +def node_edit_probabilities( + substitution: torch.Tensor, + deletion: torch.Tensor, + eps: float = 1e-8, +) -> NodeEditProbabilities: + """Combine mutually exclusive substitution and deletion probabilities.""" + raw_any = substitution + deletion + return NodeEditProbabilities( + any_edit=raw_any.clamp(0.0, 1.0), + deletion_given_edit=(deletion / raw_any.clamp_min(eps)).clamp(0.0, 1.0), + ) + + +@dataclass(frozen=True) +class InsertionStepTrace: + """Realized insertion edits in the pre-edit node index space.""" + + spawn_node_idx: torch.Tensor + clean_atoms: InsertedAtoms + bonds_to_existing: BondsToExisting | None + bonds_between_inserted: BondsBetweenInserted | None + + +@dataclass(frozen=True) +class IntegrationStepTrace: + """Edits realized by one production integration step.""" + + positions: torch.Tensor + atom_type_targets: torch.Tensor + charge_targets: torch.Tensor + node_substitutions: torch.Tensor + node_deletions: torch.Tensor + edge_index: torch.Tensor + edge_type_targets: torch.Tensor + edge_substitutions: torch.Tensor + insertions: torch.Tensor + insertion: InsertionStepTrace | None + + +@dataclass(frozen=True) +class SamplingStepTrace: + """Observable model outputs, edits, and states for one sampling step. + + Tensor references are valid during the callback. Consumers retaining a + trace should detach and clone only the fields they need. + """ + + mol_t: object + mol_t_next: object + t: torch.Tensor + dt: float | torch.Tensor + preds: dict + integration: IntegrationStepTrace + is_final: bool + + +__all__ = [ + "InsertionStepTrace", + "IntegrationStepTrace", + "NodeEditProbabilities", + "SamplingStepTrace", + "ScheduleStepMasses", + "node_edit_probabilities", +] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/scaffold_insertion.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/scaffold_insertion.py new file mode 100644 index 0000000000000000000000000000000000000000..1006e1f8bb03bd2606ada69c6462959610856beb --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/scaffold_insertion.py @@ -0,0 +1,63 @@ +"""Attachment-site constraints for scaffold-decoration insertion supervision.""" + +import torch + + +def scaffold_insertion_candidates(target, n_scaffold, future_indices, orig_to_interp): + """Eligible existing parents for each future atom, in interpolation order. + + Removing the retained scaffold from the clean target bond graph defines + substituent branches, including explicit hydrogens. A future atom may spawn + from its own branch or any scaffold atom bonded to that branch. Distinct + branches cannot borrow each other's parents, even at the same attachment + site. Multiple scaffold attachments are all retained, without an arbitrary + choice of root. Disconnected components with no scaffold attachment have no + site constraint and retain the global nearest-parent rule. + + The aligned scaffold occupies the first ``n_scaffold`` target slots. Token + zero denotes ```` by the canonical vocabulary contract. These are + training labels derived from clean target bonds, never noisy runtime bonds; + no target information is used during generation. + """ + n = target.num_nodes + if not 0 < n_scaffold <= n: + raise ValueError("A nonempty scaffold within the target is required") + if (orig_to_interp[:n_scaffold] < 0).any(): + raise ValueError("Every scaffold atom must exist in the interpolated state") + if (future_indices < n_scaffold).any(): + raise ValueError("Scaffold atoms cannot be future insertions") + + adjacency = [set() for _ in range(n)] + bonds = target.neighbor_list[target.edge_types != 0].tolist() + for left, right in bonds: + adjacency[left].add(right) + adjacency[right].add(left) + + allowed = torch.zeros((n, n), dtype=torch.bool, device=target.device) + unseen = set(range(n_scaffold, n)) + while unseen: + seed = min(unseen) + unseen.remove(seed) + branch, stack, attachments = [seed], [seed], set() + while stack: + atom = stack.pop() + for neighbor in adjacency[atom]: + if neighbor < n_scaffold: + attachments.add(neighbor) + elif neighbor in unseen: + unseen.remove(neighbor) + branch.append(neighbor) + stack.append(neighbor) + rows = torch.tensor(branch, device=target.device) + if attachments: + parents = torch.tensor(branch + sorted(attachments), device=target.device) + allowed[rows[:, None], parents[None, :]] = True + else: + allowed[rows] = True + + existing = (orig_to_interp >= 0).nonzero(as_tuple=True)[0] + existing = existing[orig_to_interp[existing].argsort()] + candidates = allowed[future_indices][:, existing] + if not candidates.any(dim=1).all(): + raise ValueError("A future insertion has no eligible existing parent") + return candidates diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/schedules.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/schedules.py new file mode 100644 index 0000000000000000000000000000000000000000..079f8c8b847bc383eb54d42453cae384721e889c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/flow_matching/schedules.py @@ -0,0 +1,186 @@ +from abc import ABC, abstractmethod + +import numpy as np +import torch +import torch.nn as nn +from scipy.stats import beta + + +class KappaSchedule(nn.Module, ABC): + """ + Abstract base class for flow-matching time schedules (kappa schedules). + + A schedule maps time t in [0, 1] to kappa(t) (CDF) and provides the rate + kappa'(t) / (1 - kappa(t)) used in rate-based flow matching. + """ + + @abstractmethod + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Maps t to kappa(t) (CDF).""" + ... + + @abstractmethod + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Derivative of kappa w.r.t. t (PDF).""" + ... + + def rate(self, t: torch.Tensor, epsilon: float = 1e-6) -> torch.Tensor: + """Rate: kappa_t_dot(t) / (1 - kappa_t(t)). + + Default implementation derived from ``kappa_t`` / ``kappa_t_dot``. + Subclasses with a cheaper closed form (e.g. ``FastPowerSchedule``) may + override. The clamp guards the division as t -> 1. + """ + return self.kappa_and_rate(t, epsilon)[1] + + def kappa_and_rate( + self, t: torch.Tensor, epsilon: float = 1e-6 + ) -> tuple[torch.Tensor, torch.Tensor]: + """Evaluate the CDF and hazard without computing the CDF twice.""" + k_t = self.kappa_t(t) + k_t_dot = self.kappa_t_dot(t) + denominator = torch.clamp(1 - k_t, min=epsilon) + return k_t, k_t_dot / denominator + + def integrated_rate( + self, t: torch.Tensor, dt, epsilon: float = 1e-6 + ) -> torch.Tensor: + """Return the exact schedule hazard integrated over ``[t, t + dt]``. + + Since ``rate = kappa' / (1 - kappa)``, its integral is the log ratio + of the survival probabilities. This avoids the left-endpoint + ``rate(t) * dt`` approximation without requiring schedule-specific code. + """ + survival = torch.clamp(1.0 - self.kappa_t(t), min=epsilon) + t_next = torch.clamp(t + dt, max=1.0) + survival_next = torch.clamp(1.0 - self.kappa_t(t_next), min=epsilon) + return torch.log(survival) - torch.log(survival_next) + + +class BetaSchedule(KappaSchedule): + """Beta-CDF schedule evaluated through SciPy for numerical stability.""" + + def __init__(self, k_alpha: float, k_beta: float): + """Initialize the Beta distribution shape parameters.""" + super().__init__() + self.k_alpha = k_alpha + self.k_beta = k_beta + + def _to_numpy(self, t: torch.Tensor): + """Helper to convert tensor to numpy array safely.""" + # Detach from graph, move to CPU, convert to numpy + return t.detach().cpu().numpy() + + def _to_tensor(self, x: np.ndarray, t_template: torch.Tensor): + """Helper to convert numpy result back to tensor on correct device.""" + return torch.tensor(x, device=t_template.device, dtype=t_template.dtype) + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the Beta CDF.""" + t_np = self._to_numpy(t) + + # scipy.stats.beta.cdf handles the math + cdf_val = beta.cdf(t_np, self.k_alpha, self.k_beta) + + return self._to_tensor(cdf_val, t) + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the Beta PDF.""" + t_np = self._to_numpy(t) + + # scipy.stats.beta.pdf handles the math + pdf_val = beta.pdf(t_np, self.k_alpha, self.k_beta) + + return self._to_tensor(pdf_val, t) + + +class CubicSchedule(KappaSchedule): + """Cubic schedule ``kappa(t) = t^3``.""" + + def __init__(self): + super().__init__() + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the cubic CDF.""" + return t**3 + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the cubic CDF derivative.""" + return 3 * t**2 + + +class LinearSchedule(KappaSchedule): + """Linear schedule ``kappa(t) = t``.""" + + def __init__(self): + super().__init__() + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the linear CDF.""" + return t + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the constant linear CDF derivative.""" + return torch.ones_like(t) + + +class FastPowerSchedule(KappaSchedule): + """Optimized Beta schedule for alpha equal to one.""" + + def __init__(self, beta: float): # e.g. 1.5 + super().__init__() + self.register_buffer("beta", torch.tensor(float(beta))) + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the analytic power-schedule CDF.""" + # Analytical CDF for Alpha=1: 1 - (1-t)^beta + return 1 - torch.pow(1 - t, self.beta) + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the analytic power-schedule PDF.""" + # Analytical PDF for Alpha=1: beta * (1-t)^(beta-1) + return self.beta * torch.pow(1 - t, self.beta - 1) + + def rate(self, t: torch.Tensor, epsilon: float = 1e-6) -> torch.Tensor: + """Evaluate the analytic power-schedule hazard.""" + # Analytical Rate for Alpha=1: beta / (1-t) + denom = torch.clamp(1 - t, min=epsilon) + return self.beta / denom + + def kappa_and_rate( + self, t: torch.Tensor, epsilon: float = 1e-6 + ) -> tuple[torch.Tensor, torch.Tensor]: + """Evaluate the analytic CDF and hazard together.""" + return self.kappa_t(t), self.rate(t, epsilon) + + +class SmoothstepSchedule(KappaSchedule): + """ + A simple polynomial schedule that concentrates edits in a bell shape. + - shift = 1.0 : Edits peak perfectly in the middle. + - shift < 1.0 : Edits happen earlier (pushes the peak towards t=0). + - shift > 1.0 : Edits happen later (pushes the peak towards t=1). + """ + + def __init__(self, shift: float = 1.0): + super().__init__() + self.shift = shift + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the time-warped smoothstep CDF.""" + # Standard smoothstep is 3t^2 - 2t^3. + # By raising t to the power of 'shift' (p), we warp time to move the peak. + p = self.shift + # Clamped because the two terms nearly cancel as t -> 1: in fp32 the + # difference can round to ~1 + 1e-7, and a CDF above 1 turns downstream + # ``(1 - kappa)`` mixing weights negative. + return (3 * t ** (2 * p) - 2 * t ** (3 * p)).clamp(0.0, 1.0) + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the time-warped smoothstep CDF derivative.""" + p = self.shift + # We clamp t slightly above 0 to prevent NaN errors when shift < 0.5 + t_safe = torch.clamp(t, min=1e-7) + + # The exact mathematical derivative of kappa_t + return 6 * p * (t_safe ** (2 * p - 1) - t_safe ** (3 * p - 1)) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..299d075aaa7c892f6663520711cf0ccd60fc402f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__init__.py @@ -0,0 +1,32 @@ +"""In-process inference utilities backing the ``morph`` CLI.""" + +from morph.inference.catalog import ModelCatalog, load_catalog +from morph.inference.generate import ( + build_sampling_module, + generate_denovo, + load_checkpoint, + make_predict_trainer, + record_mol, +) +from morph.inference.hub import ( + ModelBundle, + PretrainedModel, + download_pretrained, + load_pretrained, +) +from morph.inference.registry import ModelSpec + +__all__ = [ + "ModelBundle", + "ModelCatalog", + "ModelSpec", + "PretrainedModel", + "build_sampling_module", + "download_pretrained", + "generate_denovo", + "load_catalog", + "load_checkpoint", + "load_pretrained", + "make_predict_trainer", + "record_mol", +] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1566cb774cf24e6cf06f6ea6d61baf4811ee1edd Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/bundles.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/bundles.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1b3a0efc34630e9c684a6163961c48e0367ac83 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/bundles.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/catalog.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/catalog.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9048cf6550cce4e1ce88610c7b0096b1f74554c3 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/catalog.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/generate.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/generate.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c4ef076b3027fb340c2e7a70a07ef24214c1131 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/generate.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/hub.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/hub.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b73ebc7440d6503d6a0ae9c6f529915344d666d Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/hub.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/registry.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/registry.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a918ff901aaf610816ee0a5559be71dadd76b7aa Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/__pycache__/registry.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/bundles.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/bundles.py new file mode 100644 index 0000000000000000000000000000000000000000..56f30c3dbac384d8ea5d236a854c1889403fe427 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/bundles.py @@ -0,0 +1,162 @@ +"""Dataset-independent bundle metadata, checkpoint selection and local validation.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, replace +from pathlib import Path + +from morph.inference.registry import ModelSpec, bundle_path + +ASSET_FILES = ( + "config.yaml", + "metadata.json", + "atom_tokens.txt", + "edge_tokens.txt", + "charge_tokens.txt", + "distributions.pt", +) + + +@dataclass(frozen=True) +class ModelBundle: + """Local paths and resolved identity for a checkpoint and its inference assets.""" + + spec: ModelSpec + epoch: int | None + directory: Path + checkpoint: Path + revision: str | None + + @property + def config_path(self) -> Path: + """Portable inference configuration, not the historical training config.""" + return self.directory / "config.yaml" + + +def read_metadata(directory: Path, spec: ModelSpec) -> dict: + """Read a bundle's identity and check any expectations from the catalog.""" + metadata = json.loads((directory / "metadata.json").read_text()) + if not isinstance(metadata, dict): + raise ValueError("metadata.json must contain an object") + dataset = metadata.get("dataset") + if not isinstance(dataset, str) or not dataset.strip(): + raise ValueError("metadata.json must identify the dataset") + if spec.dataset is not None and dataset.casefold() != spec.dataset.casefold(): + raise ValueError( + f"Expected {spec.dataset} assets; found {dataset!r} in {directory}" + ) + variant = metadata.get("variant") + if spec.variant is not None and variant != spec.variant: + raise ValueError( + f"Expected {spec.variant} assets; found {variant!r} in {directory}" + ) + if variant not in {"explicit_h", "implicit_h"}: + raise ValueError("metadata.variant must be 'explicit_h' or 'implicit_h'") + for field in ("atom_tokens", "edge_tokens", "charge_tokens"): + tokens = metadata.get(field) + if ( + not isinstance(tokens, list) + or not tokens + or any(not isinstance(token, str) or not token for token in tokens) + or len(set(tokens)) != len(tokens) + ): + raise ValueError( + f"metadata.{field} must be a nonempty ordered list of unique tokens" + ) + has_h = "H" in metadata["atom_tokens"] + if has_h != (variant == "explicit_h"): + raise ValueError("Atom vocabulary does not match the hydrogen representation") + declared_h = metadata.get("includes_explicit_hydrogens", has_h) + if type(declared_h) is not bool or declared_h != has_h: + raise ValueError("includes_explicit_hydrogens does not match the vocabulary") + return metadata + + +def select_checkpoint(metadata: dict, epoch: int | None, filename: str | None): + """Select a filename from metadata, with an explicit filename escape hatch.""" + if epoch is not None and filename is not None: + raise ValueError("Choose either epoch or filename, not both") + entries = metadata.get("checkpoints", []) + if not isinstance(entries, list): + raise ValueError("metadata.checkpoints must be a list") + by_epoch = {} + for entry in entries: + if not isinstance(entry, dict): + raise ValueError( + "Each checkpoint must contain filename and completed_epochs" + ) + completed = entry.get("completed_epochs") + if type(completed) is not int or completed < 1: + raise ValueError( + "Each checkpoint must have positive integer completed_epochs" + ) + path = entry.get("filename") + if not isinstance(path, str): + raise ValueError("Each checkpoint must have a filename") + path = bundle_path(path) + if completed in by_epoch or path in by_epoch.values(): + raise ValueError("Checkpoint epochs and filenames must be unique") + by_epoch[completed] = path + if filename is not None: + filename = bundle_path(filename) + selected_epoch = next( + (e for e, path in by_epoch.items() if path == filename), None + ) + return selected_epoch, filename + if by_epoch: + epoch = max(by_epoch) if epoch is None else epoch + if epoch not in by_epoch: + raise ValueError( + f"Bundle has completed epochs {sorted(by_epoch)}; requested {epoch}" + ) + return epoch, by_epoch[epoch] + if epoch is not None: + # Support older bundles without a checkpoint manifest. + return epoch, f"epoch_{epoch:03d}.ckpt" + raise ValueError("Provide epoch or filename, or list checkpoints in metadata.json") + + +def local_bundle( + spec: ModelSpec, + directory: str | Path, + *, + epoch: int | None = None, + filename: str | None = None, + checkpoint: str | Path | None = None, + revision: str | None = None, +) -> ModelBundle: + """Resolve a direct bundle directory or export root, without dataset access.""" + directory = Path(directory).expanduser().resolve() + nested = directory / spec.subfolder + if (nested / "metadata.json").is_file() or not ( + directory / "metadata.json" + ).is_file(): + directory = nested + missing = [ + str(directory / name) + for name in ASSET_FILES + if not (directory / name).is_file() + ] + if missing: + raise FileNotFoundError( + "Incomplete model bundle. Missing: " + ", ".join(missing) + ) + metadata = read_metadata(directory, spec) + for field in ("atom_tokens", "edge_tokens", "charge_tokens"): + tokens = (directory / f"{field}.txt").read_text().splitlines() + if tokens != metadata[field]: + raise ValueError(f"{field} does not match the bundle metadata.") + if checkpoint is None: + epoch, filename = select_checkpoint(metadata, epoch, filename) + checkpoint = directory / filename + else: + if filename is not None: + raise ValueError("Choose either checkpoint or filename, not both") + checkpoint = Path(checkpoint).expanduser().resolve() + if not checkpoint.is_file(): + raise FileNotFoundError( + f"Incomplete model bundle. Missing checkpoint: {checkpoint}" + ) + spec = replace(spec, dataset=metadata["dataset"], variant=metadata["variant"]) + return ModelBundle(spec, epoch, directory, checkpoint, revision) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/catalog.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..d2e7b3b6f13584611b94c071d4da9344831d93c4 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/catalog.py @@ -0,0 +1,149 @@ +"""Discover model shortcuts from a versioned catalog on Hugging Face.""" + +# Discovery needs only JSON and the Hub client, never the model/scientific stack. +# ruff: noqa: PLC0415 + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from morph.inference.registry import ModelSpec + +DEFAULT_CATALOG_REPO = "maltefranke/morph" +CATALOG_FILENAME = "catalog.json" + + +@dataclass(frozen=True) +class ModelCatalog: + """Discovered model specifications and the catalog's own provenance.""" + + models: dict[str, ModelSpec] + default_model: str | None + path: Path + repo_id: str | None = None + revision: str | None = None + + +def _read_catalog(path: Path, *, repo_id=None, revision=None) -> ModelCatalog: + payload = json.loads(path.read_text()) + if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int: + raise ValueError("Catalog must contain an integer schema_version") + if payload["schema_version"] != 1: + raise ValueError( + f"Unsupported catalog schema_version: {payload['schema_version']}" + ) + if set(payload) - {"schema_version", "default_model", "models"}: + raise ValueError( + "Unknown catalog fields; expected schema_version, default_model, models" + ) + entries = payload.get("models") + if not isinstance(entries, list): + raise ValueError("Catalog models must be a list") + models = {} + for entry in entries: + if not isinstance(entry, dict): + raise ValueError("Each catalog model must be an object") + if set(entry) - { + "name", + "repo_id", + "subfolder", + "revision", + "dataset", + "variant", + "epochs", + }: + raise ValueError(f"Unknown fields in catalog model {entry.get('name')!r}") + name = entry.get("name") + if not isinstance(name, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._-]*", name + ): + raise ValueError( + "Catalog model names must be nonempty aliases without slashes or spaces" + ) + if name in models: + raise ValueError(f"Duplicate catalog model name: {name}") + model_repo = entry.get("repo_id") + if ( + not isinstance(model_repo, str) + or model_repo.count("/") != 1 + or not all(model_repo.split("/")) + or any(char.isspace() for char in model_repo) + ): + raise ValueError(f"Catalog model {name} must have an owner/repo repo_id") + model_revision = entry.get("revision") + if not isinstance(model_revision, str) or not model_revision.strip(): + raise ValueError(f"Catalog model {name} must declare its release revision") + for field in ("subfolder", "dataset", "variant"): + value = entry.get(field) + if value is not None and not isinstance(value, str): + raise ValueError(f"Catalog model {name}: {field} must be a string") + if "subfolder" in entry and entry["subfolder"] is None: + raise ValueError(f"Catalog model {name}: subfolder must be a string") + if entry.get("variant") not in {None, "explicit_h", "implicit_h"}: + raise ValueError(f"Catalog model {name}: unsupported hydrogen variant") + epochs = entry.get("epochs", []) + if not isinstance(epochs, list): + raise ValueError(f"Catalog model {name}: epochs must be a list") + models[name] = ModelSpec(**{**entry, "epochs": tuple(epochs)}) + default = payload.get("default_model") + if default is not None and (not isinstance(default, str) or default not in models): + raise ValueError("Catalog default_model must name an entry in models") + return ModelCatalog(models, default, path, repo_id, revision) + + +def load_catalog( + repo_id: str | None = None, + *, + revision: str | None = None, + path: str | Path | None = None, + cache_dir: str | Path | None = None, + local_files_only: bool = False, + token: str | bool | None = None, +) -> ModelCatalog: + """Read the current Hub catalog, its cached copy, or an explicit local file. + + Downloads only ``catalog.json``. The default location is controlled by + ``MORPH_CATALOG_REPO`` and ``MORPH_CATALOG_REVISION`` (default: main). + A catalog's revision is independent of the release revisions in its entries. + ``path`` bypasses the Hub and is mutually exclusive with repo_id/revision. + """ + if path is not None: + if repo_id is not None or revision is not None: + raise ValueError( + "A local catalog path cannot be combined with repo_id or revision" + ) + return _read_catalog(Path(path).expanduser().resolve()) + repo_id = repo_id or os.environ.get("MORPH_CATALOG_REPO", DEFAULT_CATALOG_REPO) + revision = revision or os.environ.get("MORPH_CATALOG_REVISION", "main") + from huggingface_hub import hf_hub_download + from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError + + try: + downloaded = hf_hub_download( + repo_id=repo_id, + filename=CATALOG_FILENAME, + revision=revision, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + ) + except LocalEntryNotFoundError as exc: + raise FileNotFoundError( + f"No cached catalog for {repo_id}@{revision}. Run `morph models list` " + "online first with the same catalog source and cache directory, " + "or supply a local catalog file." + ) from exc + except HfHubHTTPError as exc: + raise RuntimeError( + f"Cannot read {repo_id}/{CATALOG_FILENAME} at {revision}. " + "Check that the catalog exists and use `hf auth login` or HF_TOKEN " + "if the repository requires access." + ) from exc + path = Path(downloaded) + # Hub cache path is snapshots//catalog.json. Do not resolve + # the symlink: its target is a blob, which would lose revision provenance. + return _read_catalog(path, repo_id=repo_id, revision=path.parent.name) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/generate.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/generate.py new file mode 100644 index 0000000000000000000000000000000000000000..4db57ff2489d01594ae559e7abc4fd1247cf6014 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/generate.py @@ -0,0 +1,327 @@ +# Inference helpers load the scientific stack only when invoked. +# ruff: noqa: PLC0415 + +"""In-process de-novo generation for the ``morph`` CLI. + +Canonical implementation of unconditional sampling: builds the lightning module +from hydra overrides, strictly loads a current-model checkpoint, runs one predict +pass, and returns every attempted trajectory with its validity flag. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import morph.config # noqa: F401 (import registers OmegaConf resolvers) + +# A wheel carries configs under ``morph/configs``. The source checkout keeps the +# canonical tree at repository level, so editable installs use that as fallback. +_PACKAGED_CONFIG_DIR = Path(__file__).resolve().parents[1] / "configs" +_REPO_CONFIG_DIR = Path(__file__).resolve().parents[3] / "configs" +_CONFIG_DIR = ( + _PACKAGED_CONFIG_DIR if _PACKAGED_CONFIG_DIR.is_dir() else _REPO_CONFIG_DIR +) + + +def _load_repo_env() -> None: + """Make the CLI self-contained: populate ``os.environ`` from the repo ``.env`` + so ``morph generate`` works without ``--env-file .env``. + + Configs interpolate ``${oc.env:PROJECT_ROOT}`` (e.g. ``data.root_path``) which + is resolved even for dataset-free inference. We parse ``.env`` (``export + KEY=VALUE``, shell-expanded in file order so later vars can reference earlier + ones), setting only vars not already in the environment — so an explicit + ``--env-file`` / a sourced ``.env`` still wins. ``PROJECT_ROOT`` is the one + the configs hard-require; default it to the repo root when ``.env`` is absent. + """ + repo = Path(__file__).resolve().parents[3] + env_file = repo / ".env" + if env_file.exists(): + for raw in env_file.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + line = line.removeprefix("export ") + key, sep, val = line.partition("=") + key = key.strip() + if not sep or not key or key in os.environ: + continue + os.environ[key] = os.path.expandvars(val.strip().strip('"').strip("'")) + os.environ.setdefault("PROJECT_ROOT", str(repo)) + + +def _build_module(cfg): + """Build from bundle statistics without constructing a dataset.""" + import hydra + from omegaconf import OmegaConf + + from morph.model.build import build_model_module, prepare_model_data + + OmegaConf.set_struct(cfg, False) + # A fine-tuned checkpoint already contains its pretrained base weights. + if "pretrained_ckpt" in cfg.model.module: + cfg.model.module.pretrained_ckpt = None + preprocessing = hydra.utils.instantiate(cfg.data.preprocessing) + data = prepare_model_data(cfg, preprocessing.vocab, preprocessing.distributions) + module = build_model_module(cfg, data) + return module, data.vocab, preprocessing.distributions + + +def _uniform_n_atoms_range(distributions, padding: int = 5) -> tuple[int, int]: + """(min, max) atom-count bounds for the de-novo ``uniform`` n_atoms prior. + + Delegates to the training wrapper's implementation so the two paths cannot + silently acquire different source-count priors. + """ + from morph.dataset.core.flow_matching_wrapper import ( + uniform_n_atoms_range, + ) + + return uniform_n_atoms_range(distributions, padding) + + +def _prior_loader( + distributions, + n_mols: int, + batch_size: int | None = None, + uniform_n_atoms_padding: int = 5, + n_atoms_strategy: str = "uniform", +): + """A DataLoader of ``n_mols`` prior graphs — no dataset needed. + + ``batch_size`` (default: all ``n_mols`` in one batch) chunks the priors so + large ``n_mols`` (e.g. 10k) fit in GPU memory across several predict batches. + + ``distributions`` is the exact representation-aware type prior installed on + the sampling module. It matches the t=0 distribution used by + ``FlowMatchingDatasetWrapper``, which is uniform in two independent ways: + + * represented atom / charge / edge **types** have equal probability per + token; channels absent from the representation are pinned to their inert + token; + * atom **counts** follow the configured dataset-free strategy: uniform over + the padded support, or the empirical n-atoms marginal. + + Coords come from the Gaussian prior; paired with itself as ``(mol_t, mol_1)`` + since de-novo sampling reads only ``mol_t``. + """ + import torch + + from morph.dataset.core.flow_matching_wrapper import eval_collate_fn + from morph.flow_matching.prior import sample_prior_graph + + if n_atoms_strategy == "uniform": + n_min, n_max = _uniform_n_atoms_range(distributions, uniform_n_atoms_padding) + counts = torch.randint(n_min, n_max + 1, (n_mols,)).tolist() + elif n_atoms_strategy == "empirical": + counts = [None] * n_mols + else: + raise ValueError( + "dataset-free generation supports n_atoms_strategy 'uniform' or " + f"'empirical', got {n_atoms_strategy!r}" + ) + priors = [sample_prior_graph(distributions, n_atoms=n) for n in counts] + return torch.utils.data.DataLoader( + [(p, p) for p in priors], + batch_size=batch_size or n_mols, + shuffle=False, + num_workers=0, + collate_fn=eval_collate_fn, + ) + + +def load_checkpoint(module, checkpoint_path) -> None: + """Strictly load an eager or ``torch.compile`` checkpoint.""" + import torch + + from morph.utils.checkpoints import state_dict_for_module + + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + sd = ckpt.get("state_dict", ckpt) + module.load_state_dict(state_dict_for_module(sd, module), strict=True) + + +def build_sampling_module(overrides, checkpoint=None): + """Compose ``overrides`` and build a sampling-ready module — no dataset access. + + The reusable entry point for every in-process inference mode (de-novo or + scaffold): returns ``(module, vocab, distributions, cfg)`` and, when + ``checkpoint`` is given, loads it. Works for any ``cfg.model.module`` target. + Compiles both current and EMA models unless ``MORPH_COMPILE=0``. + """ + from hydra import compose, initialize_config_dir + + from morph.utils.run_config import configure_runtime + + _load_repo_env() + configure_runtime() + + with initialize_config_dir(config_dir=str(_CONFIG_DIR), version_base="1.1"): + cfg = compose(config_name="default", overrides=list(overrides)) + module, vocab, distributions = _build_module(cfg) + if checkpoint is not None: + load_checkpoint(module, Path(checkpoint)) + module.compile() + return module, vocab, distributions, cfg + + +def make_predict_trainer(cfg): + """A single-GPU (auto-accelerator) predict Trainer with logging/ckpt off.""" + import pytorch_lightning as pl + + tk = dict(cfg.trainer.trainer) + # ``num_val_samples`` is a Morph setup setting, not a Trainer argument. + tk.pop("num_val_samples", None) + if tk.get("limit_val_batches") is None: + tk.pop("limit_val_batches", None) + tk["strategy"] = "auto" + tk["devices"] = 1 + tk["limit_predict_batches"] = 1.0 + return pl.Trainer(logger=False, callbacks=[], enable_checkpointing=False, **tk) + + +def generate_denovo( + checkpoint, + overrides, + n_mols: int = 16, + batch_size: int | None = None, + seed: int = 42, + *, + return_traj: bool = False, +): + """Sample ``n_mols`` de-novo molecules from an unconditional checkpoint. + + No dataset is loaded: priors are drawn directly from the training + distributions. Returns ``(trajectories, is_valid, vocab, coordinate_std)``. + By default each trajectory contains only the final frame; pass + ``return_traj=True`` to retain all integration frames. Current and EMA models + are compiled by default; set ``MORPH_COMPILE=0`` to disable compilation. + Trajectories stay in attempt order (frames in Angstrom; + ``trajectories[i][-1]`` is the generated molecule), including failures, so + requesting ``n_mols`` never silently changes the denominator. + """ + import pytorch_lightning as pl + + pl.seed_everything(seed) + module, vocab, _distributions, cfg = build_sampling_module(overrides, checkpoint) + return sample_module( + module, + vocab, + cfg, + n_mols=n_mols, + batch_size=batch_size, + seed=None, + return_traj=return_traj, + ) + + +def sample_module( + module, + vocab, + cfg, + n_mols: int = 16, + batch_size: int | None = None, + seed: int | None = 42, + *, + return_traj: bool = False, +): + """Sample an already loaded module; return every attempt and its validity.""" + import pytorch_lightning as pl + + if n_mols < 1 or (batch_size is not None and batch_size < 1): + raise ValueError("n_mols and batch_size must be positive") + if seed is not None: + pl.seed_everything(seed) + module.predict_return_traj = return_traj + + # Use the module's prior directly: the initial graph and every insertion + # renoising step therefore share the exact same categorical probabilities. + loader = _prior_loader( + module.distributions, + n_mols, + batch_size, + uniform_n_atoms_padding=int(cfg.data.get("uniform_n_atoms_padding", 5)), + n_atoms_strategy=str(cfg.data.n_atoms_strategy), + ) + preds = make_predict_trainer(cfg).predict(module, dataloaders=loader) + + trajectories = [] + is_valid = [] + for pr in preds or []: + if isinstance(pr, dict): + batch_mols = pr.get("all_mols") or [] + batch_valid = pr.get("is_valid") or [] + if len(batch_mols) != len(batch_valid): + raise RuntimeError( + "Prediction output has misaligned molecules and validity flags" + ) + trajectories.extend(batch_mols) + is_valid.extend(bool(v) for v in batch_valid) + coordinate_std = float(module.distributions.coordinate_std) + if len(trajectories) != n_mols: + raise RuntimeError( + f"Requested {n_mols} molecules, received {len(trajectories)}" + ) + return trajectories[:n_mols], is_valid[:n_mols], vocab, coordinate_std + + +def final_mol(traj, vocab): + """RDKit mol of a trajectory's final frame (keeps the 3D conformer), or None.""" + frame = traj[-1] if isinstance(traj, list) else traj + try: + return frame.to_rdkit_mol( + vocab.atom_tokens, vocab.edge_tokens, vocab.charge_tokens + ) + except Exception: + return None + + +def record_mol(traj, vocab) -> dict: + """Summarize a trajectory's final molecule (SMILES / mol block / N / logP).""" + from rdkit import Chem + from rdkit.Chem import Crippen + + mol = final_mol(traj, vocab) + if mol is None: + return { + "valid": False, + "smiles": None, + "molblock": None, + "n_atoms": None, + "logp": None, + } + try: + lp = float(Crippen.MolLogP(mol)) + except Exception: + lp = None + return { + "valid": True, + "smiles": Chem.MolToSmiles(mol), + "molblock": Chem.MolToMolBlock(mol), + "n_atoms": Chem.AddHs(mol).GetNumAtoms(), + "logp": lp, + } + + +def write_sdf(leads, vocab, path) -> tuple[int, int]: + """Write each trajectory's final-frame molecule (3D coords + SMILES name) to an + SDF. Invalid molecules are skipped. Returns ``(n_written, n_total)``. + """ + from rdkit import Chem + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + writer = Chem.SDWriter(str(path)) + n_written = 0 + try: + for traj in leads: + mol = final_mol(traj, vocab) + if mol is None: + continue + mol.SetProp("_Name", Chem.MolToSmiles(mol)) + writer.write(mol) + n_written += 1 + finally: + writer.close() + return n_written, len(leads) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/hub.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/hub.py new file mode 100644 index 0000000000000000000000000000000000000000..f24ca9c20f401dfdc0ea1f303494834d3757f98c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/hub.py @@ -0,0 +1,260 @@ +"""Download and load complete Hugging Face model bundles without a dataset.""" + +# Keep downloads and CLI discovery independent of the scientific stack. +# ruff: noqa: PLC0415 + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from morph.inference.bundles import ( + ASSET_FILES, + ModelBundle, + local_bundle, + read_metadata, + select_checkpoint, +) +from morph.inference.registry import ModelSpec, bundle_path, get_model + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.vocab import Distributions, Vocab + from morph.inference.catalog import ModelCatalog + from morph.inference.module import InferenceModule + + +def download_pretrained( + name: str | ModelSpec | None = None, + *, + epoch: int | None = None, + subfolder: str | None = None, + revision: str | None = None, + filename: str | None = None, + catalog: ModelCatalog | None = None, + cache_dir: str | Path | None = None, + local_dir: str | Path | None = None, + local_files_only: bool = False, + token: str | bool | None = None, +) -> ModelBundle: + """Download a checkpoint and assets from a catalog entry or HF repository. + + Unregistered repositories use ``subfolder`` and their ``metadata.json`` to + select checkpoints. ``filename`` overrides epoch selection. A branch or tag + is resolved once so the metadata, config and weights share a commit. + Authentication uses ``hf auth login`` or ``HF_TOKEN`` unless supplied here. + """ + spec = get_model( + name, + subfolder=subfolder, + revision=revision, + catalog=catalog, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + ) + if spec.repo_id is None: + raise ValueError("Downloading requires a ModelSpec with repo_id") + if filename is not None: + filename = bundle_path(filename) + if epoch is not None: + raise ValueError("Choose either epoch or filename, not both") + else: + epoch = spec.select_epoch(epoch) + from huggingface_hub import snapshot_download + from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError + + prefix = f"{spec.subfolder}/" if spec.subfolder else "" + download_args = dict( + repo_id=spec.repo_id, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + max_workers=4, + ) + try: + # HF snapshots end in the resolved commit hash, including offline reads. + # Always resolve metadata in the cache, even when exporting to local_dir. + snapshot = snapshot_download( + **download_args, + revision=spec.revision, + allow_patterns=[f"{prefix}metadata.json"], + ) + resolved_revision = Path(snapshot).name + metadata = read_metadata(Path(snapshot) / spec.subfolder, spec) + epoch, selected_file = select_checkpoint(metadata, epoch, filename) + root = snapshot_download( + **download_args, + revision=resolved_revision, + local_dir=local_dir, + allow_patterns=[ + f"{prefix}{path}" for path in (*ASSET_FILES, selected_file) + ], + ) + return local_bundle( + spec, + Path(root) / spec.subfolder, + epoch=epoch if filename is None else None, + filename=selected_file if filename is not None else None, + revision=resolved_revision, + ) + except LocalEntryNotFoundError as exc: + raise FileNotFoundError( + f"{spec.name} is not available in the local cache. " + "Run `morph models download` online with the same model, subfolder " + "and checkpoint selection first." + ) from exc + except HfHubHTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status in {401, 403, 404}: + raise RuntimeError( + f"Cannot access {spec.repo_id}. Run `hf auth login` with an account " + "that can read the repository, or set HF_TOKEN. " + f"Requested revision: {spec.revision or 'main'}." + ) from exc + raise + + +@dataclass +class PretrainedModel: + """Loaded model, vocabulary, empirical statistics and sampling configuration.""" + + module: InferenceModule + vocab: Vocab + distributions: Distributions + config: DictConfig + bundle: ModelBundle + + def generate( + self, + n_mols: int = 16, + *, + batch_size: int | None = None, + seed: int = 42, + return_traj: bool = False, + ) -> tuple[list, list[bool], Any, float]: + """Return (trajectories, validity_flags, vocab, coordinate_std). + + Every attempted molecule is retained. Coordinates are in Angstrom. + Each trajectory contains its final frame unless ``return_traj=True``. + Sampling uses EMA weights by default and never loads a dataset. + """ + from morph.inference.generate import sample_module + + return sample_module( + self.module, + self.vocab, + self.config, + n_mols=n_mols, + batch_size=batch_size, + seed=seed, + return_traj=return_traj, + ) + + +def load_pretrained( + name: str | ModelSpec | None = None, + *, + epoch: int | None = None, + subfolder: str | None = None, + revision: str | None = None, + filename: str | None = None, + catalog: ModelCatalog | None = None, + device: str = "cpu", + weights_dir: str | Path | None = None, + checkpoint: str | Path | None = None, + cache_dir: str | Path | None = None, + local_files_only: bool = False, + token: str | bool | None = None, + steps: int = 100, + use_ema: bool = True, +) -> PretrainedModel: + """Load a catalog name, HF owner/repo ID, ModelSpec, or local bundle. + + With only ``weights_dir``, identity and available epochs come from the bundle + metadata; the default catalog model is not consulted. ``subfolder``, + ``revision`` and ``filename`` support unregistered Hub releases. + Download only the selected checkpoint unless ``weights_dir`` supplies a + local bundle (no network). ``checkpoint`` can override the weights in a local + bundle; its architecture and vocabulary must match. Construction uses the + saved portable config, not today's training defaults. Only the selected EMA + or current network is constructed and loaded, with strict key/shape checks. + No remote Python code is imported. The returned module is eager, in + evaluation mode, and on ``device``, with gradients disabled. + """ + if name is None and weights_dir is not None: + spec = ModelSpec(Path(weights_dir).expanduser().name, subfolder=subfolder or "") + else: + spec = get_model( + name, + subfolder=subfolder, + revision=revision, + catalog=catalog, + cache_dir=cache_dir, + local_files_only=local_files_only or weights_dir is not None, + token=token, + ) + if filename is not None: + filename = bundle_path(filename) + if epoch is not None or checkpoint is not None: + raise ValueError("filename cannot be combined with epoch or checkpoint") + else: + epoch = spec.select_epoch(epoch) + if device not in {"auto", "cpu", "cuda"}: + raise ValueError("device must be 'auto', 'cpu', or 'cuda'") + if steps < 1: + raise ValueError("steps must be positive") + if checkpoint is not None and weights_dir is None: + raise ValueError( + "checkpoint requires weights_dir with matching inference assets" + ) + if weights_dir is None: + bundle = download_pretrained( + spec, + epoch=epoch, + filename=filename, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + ) + else: + bundle = local_bundle( + spec, weights_dir, epoch=epoch, filename=filename, checkpoint=checkpoint + ) + + import torch + from omegaconf import OmegaConf + + from morph.inference.module import build_inference_module, load_inference_weights + + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + if device == "cuda" and not torch.cuda.is_available(): + raise RuntimeError("CUDA is unavailable. Use device='cpu' or a GPU worker.") + cfg = OmegaConf.load(bundle.config_path) + dataset = cfg.data.get("dataset_name") + if ( + dataset is not None + and str(dataset).casefold() != bundle.spec.dataset.casefold() + ): + raise ValueError("Saved config dataset_name does not match bundle metadata") + cfg.data.preprocessing.root = str(bundle.directory) + cfg.data.preprocessing.train_dataset = None + cfg.data.preprocessing.num_workers = 0 + for field in ("atom_tokens", "edge_tokens", "charge_tokens", "distributions"): + suffix = "pt" if field == "distributions" else "txt" + cfg.data.preprocessing[f"{field}_path"] = str( + bundle.directory / f"{field}.{suffix}" + ) + cfg.integrator.integrator.num_integration_steps = steps + cfg.model.module.use_ema_for_eval = use_ema + cfg.trainer.trainer.accelerator = "gpu" if device == "cuda" else "cpu" + cfg.trainer.trainer.devices = 1 + cfg.trainer.trainer.strategy = "auto" + cfg.logging.enabled = False + module, vocab, distributions = build_inference_module(cfg) + load_inference_weights(module.model, bundle.checkpoint, use_ema=use_ema) + module.eval().to(device) + return PretrainedModel(module, vocab, distributions, cfg, bundle) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/module.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/module.py new file mode 100644 index 0000000000000000000000000000000000000000..fc60035fa4ec1a36653cf22f7936f745971a6930 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/module.py @@ -0,0 +1,100 @@ +"""Prediction-only module: one network, with either EMA or current weights.""" + +from __future__ import annotations + +import os +from collections import OrderedDict +from typing import TYPE_CHECKING + +import hydra +import pytorch_lightning as pl +import torch + +from morph.model.build import prepare_model_data +from morph.model.layers.attention import fix_efficient_attention_bias_metadata +from morph.model.lightning._sampling import SamplingMixin +from morph.utils.checkpoints import canonicalize_state_dict, state_dict_for_module + +if TYPE_CHECKING: + from pathlib import Path + + from omegaconf import DictConfig + + from morph.model.build import ModelData + + +class InferenceModule(SamplingMixin, pl.LightningModule): + """Reuse the production sampler without constructing training or EMA copies.""" + + def __init__(self, cfg: DictConfig, data: ModelData): + super().__init__() + self.model = hydra.utils.instantiate(cfg.model.module.model) + self.vocab = data.vocab + self.distributions = data.prior + self.representation = data.representation + self.allow_charged = data.allow_charged + self.integrator = hydra.utils.instantiate( + cfg.model.module.integrator, distributions=data.prior + ) + self.weights = "ema" if cfg.model.module.use_ema_for_eval else "current" + self.is_compiled = False + + def _get_model(self): + return self.model + + def forward(self, *args, **kwargs): + """Forward through the selected inference network.""" + return self.model(*args, **kwargs) + + def compile(self): + """Compile the single network unless MORPH_COMPILE=0.""" + if not self.is_compiled and os.environ.get("MORPH_COMPILE", "1") != "0": + fix_efficient_attention_bias_metadata() + self.model.compile(dynamic=True) + self.is_compiled = True + + +def build_inference_module(cfg): + """Build one unconditional network from the saved config and bundle assets.""" + if cfg.model.module._target_ != "morph.model.lightning.base.LightningModuleRates": + raise ValueError( + "The pretrained inference loader supports unconditional models." + ) + preprocessing = hydra.utils.instantiate(cfg.data.preprocessing) + data = prepare_model_data(cfg, preprocessing.vocab, preprocessing.distributions) + return InferenceModule(cfg, data), data.vocab, data.empirical + + +def load_inference_weights( + model: torch.nn.Module, checkpoint_path: str | Path, *, use_ema: bool = True +) -> None: + """Strictly load just one network from a memory-mapped Lightning checkpoint. + + The unselected network and optimizer tensor storage remain on disk. After + copying the selected tensors into ``model``, local mappings are released. + Missing EMA weights are an error, never a fallback to current weights. + """ + checkpoint = torch.load( + checkpoint_path, map_location="cpu", weights_only=False, mmap=True + ) + canonical = canonicalize_state_dict(checkpoint.get("state_dict", checkpoint)) + prefix = "model_ema." if use_ema else "model." + selected = OrderedDict( + (name.removeprefix(prefix), tensor) + for name, tensor in canonical.items() + if name.startswith(prefix) + ) + if not selected: + raise RuntimeError( + f"Checkpoint has no {prefix[:-1]} weights: {checkpoint_path}" + ) + metadata = getattr(canonical, "_metadata", None) + if metadata is not None: + selected._metadata = { + ("" if name == prefix[:-1] else name.removeprefix(prefix)): value + for name, value in metadata.items() + if name == prefix[:-1] or name.startswith(prefix) + } + model.load_state_dict(state_dict_for_module(selected, model), strict=True) + model.requires_grad_(False) + model.eval() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/registry.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..bd0de35c8a6ab6ab40fe2a99e6494c55273c0ceb --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/inference/registry.py @@ -0,0 +1,109 @@ +"""Model specifications and name resolution through a discoverable Hub catalog.""" + +from dataclasses import dataclass, replace +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + from morph.inference.catalog import ModelCatalog + + +def bundle_path(value: str, *, allow_empty: bool = False) -> str: + """Validate a literal relative path inside a model bundle or repository.""" + if not value and allow_empty: + return "" + path = PurePosixPath(value) + if ( + not value + or path.is_absolute() + or ".." in path.parts + or path.as_posix() == "." + or any(char in value for char in "\\*?[]") + ): + raise ValueError( + f"Expected a relative bundle path without glob patterns: {value!r}" + ) + return path.as_posix() + + +@dataclass(frozen=True) +class ModelSpec: + """Location and optional identity checks for any compatible model bundle. + + An empty ``epochs`` tuple defers selection to ``metadata.json``. Each catalog + entry owns its repository, subfolder and revision; none are dataset defaults. + ``repo_id=None`` describes local-only assets. + """ + + name: str + repo_id: str | None = None + subfolder: str = "" + revision: str | None = None + dataset: str | None = None + variant: str | None = None + epochs: tuple[int, ...] = () + + def __post_init__(self): + """Normalize the bundle path and validate optional published epochs.""" + object.__setattr__( + self, "subfolder", bundle_path(self.subfolder, allow_empty=True) + ) + if any(type(epoch) is not int or epoch < 1 for epoch in self.epochs): + raise ValueError("Catalog epochs must be positive completed-epoch integers") + if len(set(self.epochs)) != len(self.epochs): + raise ValueError("Catalog epochs must be unique") + + def select_epoch(self, epoch: int | None = None) -> int | None: + """Use the latest catalog epoch, or defer to the bundle metadata.""" + if epoch is not None and (type(epoch) is not int or epoch < 1): + raise ValueError("epoch must be a positive completed-epoch integer") + selected = max(self.epochs) if epoch is None and self.epochs else epoch + if self.epochs and selected not in self.epochs: + raise ValueError( + f"{self.name} has epochs {', '.join(map(str, self.epochs))}; " + f"requested {selected}. Epochs count completed training epochs." + ) + return selected + + +def get_model( + name: str | ModelSpec | None = None, + *, + subfolder: str | None = None, + revision: str | None = None, + catalog: "ModelCatalog | None" = None, + cache_dir: "str | Path | None" = None, + local_files_only: bool = False, + token: str | bool | None = None, +) -> ModelSpec: + """Resolve a catalog name, explicit specification, or HF ``owner/repo`` ID.""" + if isinstance(name, ModelSpec): + spec = name + elif isinstance(name, str) and name.count("/") == 1 and all(name.split("/")): + spec = ModelSpec(name, repo_id=name) + else: + # Explicit specifications and repository IDs never need catalog access. + from morph.inference.catalog import load_catalog # noqa: PLC0415 + + if catalog is None: + catalog = load_catalog( + cache_dir=cache_dir, local_files_only=local_files_only, token=token + ) + selected = catalog.default_model if name is None else name + if selected is None: + raise ValueError("This catalog has no default model; choose a model name") + try: + spec = catalog.models[selected] + except KeyError: + raise ValueError( + f"Unknown model {selected!r}. Available: {', '.join(catalog.models)}. " + "You can also pass a Hugging Face owner/repo ID or ModelSpec." + ) from None + if subfolder is not None and subfolder != spec.subfolder: + # A different bundle in the same repository has its own identity/epochs. + spec = replace(spec, subfolder=subfolder, dataset=None, variant=None, epochs=()) + if revision is not None and revision != spec.revision: + spec = replace(spec, revision=revision, epochs=()) + return spec diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/README.md b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d8f28600adbce10e359cf262e88682644cb942e8 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/README.md @@ -0,0 +1,199 @@ +# Model package + +The repository has one first-class generative architecture: the molecular DiT +in `backbones/dit.py`. Conditional applications wrap that backbone without +forking its clean denoising lane. + +## Layout + +| Area | Purpose | +| --- | --- | +| `backbones/dit.py` | Molecular DiT backbone and prediction-head assembly | +| `backbones/conditional_dit.py` | Optional node, edge, and cross-attention ports | +| `layers/attention.py` | Pair bias, geometric attention, and context-distance bias | +| `layers/heads.py` | Node, edge, insertion, deletion, and coordinate heads | +| `layers/lora.py` | Checkpoint-compatible low-rank linear adapters | +| `lightning/base.py` | The single Lightning module used by the DiT models | +| `lightning/_optimizer.py` | Muon/AdamW routing, stepping, clipping, and schedulers | +| `finetune/base/` | Shared checkpoint loading and conditional encoder components | +| `finetune/scaffold/` | Scaffold-decoration Lightning module and backbone wrapper | + +Task packages re-export their public classes. Hydra targets therefore use paths +such as +`morph.model.finetune.scaffold.ScaffoldConditionalDiTBackboneWithHeads`, not +leaf-module or legacy flat paths. + +## First-class DiT + +`DiTBackboneWithHeads` embeds atoms, edges, diffusion time, and current graph +size, applies the DiT stack, and exposes the complete rate-model output +contract. Its pair representation is directed during featurization: each +directed edge receives its own source/target node features. Symmetry is imposed +only on prediction lanes whose mathematical targets are symmetric. + +The default config is `configs/model/backbones/dit.yaml`. It uses the shared +Muon/AuxAdam optimizer recipe and the tuned multi-head loss weights. + +`model.module.model.incident_bond_embedding=true` adds the sum of each atom's +existing incident edge embeddings, excluding `NO_BOND`, through a +zero-initialized linear projection into its node embedding. It preserves bond +counts, shares the edge embedding parameters with attention and the decoder, +and uses the existing prediction heads. + +For GEOM, `data.interpolator.atom_type_noise_prob=0.01` adds training-only +categorical corruption after interpolation. Selected tokens are redrawn from +empirical alternatives, excluding their current value. The probability for +each undirected pair is scaled to match the expected number of atom changes; +clean targets, deletion-only nodes, and locked scaffold types/bonds are retained. +The default is zero. This augmentation does not change inference schedules. + +Low-rank adaptation is opt-in through `model.lora`. The +`finetune/lora_finetune` model recipe loads a pretrained unconditional model, +freezes its ordinary parameters, and trains LoRA matrices on the attention and +SwiGLU block projections with AdamW. The adapter residual is exactly zero at +initialization, and its base `weight`/`bias` state-dict names are unchanged. + +The insertion-content head is a separate, explicit Hydra choice. The default is +`insertion_head=unimodal`; use `insertion_head=gmm` for the mixture-density +alternative. Common state/edit heads live in `configs/heads/head.yaml`, while +the two insertion-head recipes live under `configs/insertion_head/`. + +## Tensor contract + +Let `B` be the number of molecules, `N` the total current atoms, `E` the +directed edges, `A`/`C`/`T` the atom/charge/bond vocabulary sizes, and `K` the +number of mixture components. Nodes and edges are contiguous by molecule. +Runtime graphs contain both directions of every non-self atom pair, including +the `` token. Output edges follow the input `edge_index` order. + +`model(mols_t, t, max_seqlen=...)` receives: + +| Input | Shape | Meaning | +| --- | --- | --- | +| `mols_t.positions` (`x`) | `[N, 3]` | Centered coordinates divided by training `coordinate_std` | +| `mols_t.atom_types` (`a`) | `[N]` | Integer atom-token indices | +| `mols_t.charge_types` (`c`) | `[N]` | Charge-token indices; retained in the container, not embedded | +| `mols_t.edge_types` (`e`) | `[E]` | Integer bond-token indices | +| `mols_t.edge_index` | `[2, E]` | Directed atom pairs; integer indices into current nodes | +| `mols_t.batch` | `[N]` | Molecule index for each node | +| `t` | `[B, 1]` | Interpolation time for each molecule | +| `max_seqlen` | Python integer | Largest current graph, resolved before a compiled forward pass | + +The output dictionary contains: + +| Key | Shape | Meaning | +| --- | --- | --- | +| `pos_head` | `[N, 3]` | Predicted clean coordinates in normalized units | +| `atom_type_head` | `[N, A]` | Clean atom-type logits | +| `charge_head` | `[N, C]` | Clean charge logits | +| `edge_type_head` | `[E, T]` | Clean bond-type logits, symmetric in endpoints | +| `do_sub_a_head` | `[N, 1]` | Logit for a pending atom substitution | +| `do_sub_e_head` | `[E, 1]` | Logit for a pending bond substitution, symmetric in endpoints | +| `do_del_head` | `[N, 1]` | Logit for a pending deletion | +| `ins_rate_head` | `[N, 1]` | Positive expected number of pending insertions per spawning node | +| `h_latent` | `[N, head_node_dim]` | Node features used by insertion-edge prediction | +| `insertion_head` | dictionary | Clean inserted-atom predictions, described below | + +Unimodal insertion outputs are `mu: [N, 3]`, `a_logits: [N, A]`, and +`c_logits: [N, C]`. Each spawning node is supervised with its nearest assigned +pending insertion. The mean is the spawning atom's position plus a learned offset. + +GMM insertion outputs are `mu: [N, K, 3]`, `sigma: [N, K]`, `pi: [N, K]`, +`a_probs: [N, K, A]`, and `c_probs: [N, K, C]`. `sigma` is an isotropic standard +deviation in normalized units; `pi`, `a_probs`, and `c_probs` are probabilities. +The mixture likelihood supervises every pending insertion assigned to a node. + +Sampling applies sigmoid to edit logits and samples categorical logits. The +integrator multiplies edit posteriors and expected insertion counts by schedule +masses. At most one insertion per spawning node is applied in a numerical step. +Inserted bonds are predicted from clean inserted features before insertion noise +is applied. Final samples and saved trajectory frames are in Angstroms. Frozen +scaffold coordinates are restored to their input frame. + +## Training targets and loss contract + +Training receives `(mols_t, mols_1, insertion_targets, t)`. The first two states +share current-node and edge ordering; `mols_1` contains their clean targets. +`InsertionTargets` retains the future atoms and two structured bond groups. +Its spawn/existing indices address `mols_t`; inserted indices address the future +atoms. `collate_insertion_targets` offsets these spaces independently without +mutating individual samples. + +Numerical `lambda_ins`, `lambda_del`, `lambda_a_sub`, and `lambda_e_sub` values +are retained on `mols_t`. Binary labels and supervision masks use their `> 0` +tests. `n_ins` is the pending insertion count used by Poisson NLL. + +Training helpers return named `LossTerm(values, graph_mask)` entries. Values have +shape `[B, ...]`; masks, when present, have shape `[B]`. Time weights are applied +per molecule before averaging. Empty supervision produces zero-valued terms with +false masks and retains autograd dependencies required by distributed training. + +| Loss | Supervision | +| --- | --- | +| `do_sub_a`, `do_sub_e`, `do_del` | Binary cross-entropy against positive lambda values | +| `sub_a_class`, `sub_e_class` | Cross-entropy on substitutions, excluding deletion targets and task-protected states | +| `ins_rate` | Poisson NLL on all current nodes, including zero insertion counts | +| `ins_state` | Unimodal position/type/charge loss or joint typed-GMM NLL | +| `ins_e`, `ins_e_ii` | Bond cross-entropy using target inserted-atom features | +| `x` | Coordinate MSE on supervised, non-deletion nodes | +| `c` | Charge cross-entropy on supervised, non-deletion nodes | + +The representation controls charge and topology loss participation. Position +and charge time weighting is always active; `use_time_weights` additionally +enables schedule weighting for edit loss groups. Component weights come from +`configs/loss_weights/`. + +## Conditional tasks + +The conditional backbone composes the clean DiT and introduces zero-gated +conditioning ports. At initialization, a conditional model is therefore +identity-equivalent to the loaded unconditional checkpoint. + +- Scaffold decoration derives node and edge conditions from `keep_mask`. It can + also encode the scaffold subgraph as cross-attention context with a geometric + distance bias. `freeze_keep_positions` controls whether kept coordinates are + fixed (and returned in their input frame) or allowed to flow; discrete and + coordinate loss exclusions remain independently configurable. + +The fine-tuning base loads unconditional keys into the conditional clean lane, +resynchronizes EMA weights, and optionally freezes pretrained modules according +to each task wrapper's `freeze_pretrained()` policy. + +`ConditionalDiTBackbone.forward` accepts these optional pre-embedded ports: + +| Port | Shape | Meaning | +| --- | --- | --- | +| `cond_tokens` | `[B, L, ctx_dim]` | External context tokens | +| `cond_token_mask` | `[B, L]` | Boolean, true for valid context tokens | +| `cond_token_attn_bias` | `[B, L, N_max]` or `[B, H, L, N_max]` | Additive token-to-atom attention bias | +| `node_cond` | `[N, d_model]` | Conditions aligned to current nodes | +| `edge_cond` | `[E, in_edge_nf]` | Conditions aligned to current directed edges | + +Here `L` is context length and `H` the number of attention heads. Each port +has its own zero-initialized tanh gate. `keep_mask` is a node-level boolean +mask carried through topology edits; newly inserted nodes are not retained atoms. + +## Config entry points + +The task-oriented Hydra groups mirror the source layout: + +```text +configs/model/backbones/dit.yaml +configs/model/finetune/scaffold_finetune.yaml +configs/data/qm9/{qm9,scaffold_decoration}.yaml +configs/data/geom/{geom,scaffold_decoration}.yaml +configs/defaults/qm9/{qm9,scaffold_decoration}.yaml +configs/defaults/geom/{geom,scaffold_decoration}.yaml +``` + +Examples: + +```bash +python run.py defaults=qm9/qm9 +python run.py defaults=geom/geom +python run.py defaults=qm9/scaffold_decoration \ + model.module.pretrained_ckpt=/path/to/unconditional.ckpt +``` + +There are intentionally no compatibility aliases for the previous flat source +modules or Hydra group names. diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1497c59eef37001653fa1f7767cac1a31c4e5ac0 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__init__.py @@ -0,0 +1 @@ +"""Model package: backbones, reusable layers, losses, and Lightning modules.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac0ae573b828f94ed92963b18831cf5ca74455a5 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__pycache__/build.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__pycache__/build.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eae264b2bd78151f9598aef1a17717b6ceb57d48 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/__pycache__/build.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d4ba7a4c605c984ddfedbbe7028fdd62086396e --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__init__.py @@ -0,0 +1 @@ +"""The molecular DiT backbone and its conditional extension.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a20c2b759f96a364b17fba64b4cf3f3d71901df Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/conditional_dit.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/conditional_dit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e840a5b5512011809d3806c439d90ca091aba7a4 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/conditional_dit.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/dit.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/dit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab7c634645b9ef6a9e1cbe235d557a1fcc7a7d38 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/__pycache__/dit.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/conditional_dit.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/conditional_dit.py new file mode 100644 index 0000000000000000000000000000000000000000..5491ec6a850d540a76ade8cb45d7cd93e52586a0 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/conditional_dit.py @@ -0,0 +1,620 @@ +""" +Conditional DiT backbone: a :class:`DiTBackbone` with three **independent, +opt-in** conditioning ports (cross-attention / node / edge). With every +port disabled it is functionally identical to the plain ``DiTBackbone``; each +enabled port adds a zero-init gated stream. + +**Embed-outside contract.** The backbone performs *no* modality-specific +embedding. Every conditioning input arrives already at its port's fusion width +from an external encoder, and the backbone only does zero-init ``tanh``-gated +fusion (plus the intrinsic per-block attention K/V projection). A freshly loaded +unconditional DiT is therefore bit-identical to its baseline until the gates +train away from 0. Each port is skipped (free pass-through) when its kwarg is +``None``. + +* **Cross-attention conditioning** (``cond_tokens`` ``(B, K, ctx_dim)``): atoms + attend to a set of ``K`` external context tokens via gated cross-attention + (Flamingo-style) inserted before each ``DiTBlock``. The tokens are *not* tied + to any one modality or to a sequential structure — today they are per-atom + encodings of a scaffold, but they can + be any token set (e.g. language tokens). ``K`` is arbitrary and need not + relate to the atom count. Logits accept an additive + per-pair bias ``(B, K, N_padded)`` to seed prior coupling between specific + tokens and atoms. + +* **Node conditioning** (``node_cond`` ``(N, d_model)``): added to the per-atom + hidden state ``h`` through ``tanh(node_gate)`` right after ``self.dit.encode``. + 1-1 aligned with the denoised atoms (ragged batch order), so no attention. + +* **Edge conditioning** (``edge_cond`` ``(E, in_edge_nf)``): added to + ``e_embed`` through ``tanh(edge_cond_gate)``. Already 1-1 aligned with edges, + so no attention. + +Each port's parameters (the ``cross_attn_blocks`` and the node/edge gates) are +only built when its ``use_*`` flag is set, so unused parameters never enter +the graph (the trainer runs ``ddp_find_unused_parameters_false``). + +Composition (not inheritance): :class:`ConditionalDiTBackbone` *holds* a +:class:`morph.model.backbones.dit.DiTBackbone` instance as ``self.dit`` and, +when ``use_cross_attn`` is set, a parallel ``self.cross_attn_blocks`` ModuleList. +Its forward calls the DiT's :meth:`encode`/:meth:`decode` helpers and iterates +``self.dit.blocks``, interleaving cross-attention between them when enabled. To +load a pretrained DiT, use :meth:`ConditionalDiTBackbone.load_dit_state_dict`, +which forwards to ``self.dit.load_state_dict``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import to_dense_batch + +from morph.model.backbones import dit as _dit +from morph.model.backbones.dit import ( + DiTBackbone, + DiTBackboneWithHeads, + SwiGLU, + _swiglu_width, +) +from morph.model.layers.attention import compute_dense_index, pad_dense, unpad_dense +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.atomic_data import ChemBatch + +# --------------------------------------------------------------------------- +# Gated cross-attention block (Flamingo-style) +# --------------------------------------------------------------------------- + + +class GatedCrossAttention(nn.Module): + """Gated cross-attention: atoms (queries) attend to conditioning tokens. + + Both the attention and feed-forward branches are wrapped in a tanh gate + on a learnable scalar initialised to 0, so the layer is the identity at + init (Flamingo, https://arxiv.org/abs/2204.14198). + + Attention logits accept an additive per-pair bias of shape ``(B, K, N)`` + (token-then-atom indexing); ``bias[b, k, n]`` is added to the logit of + atom ``n`` attending to token ``k``. + """ + + def __init__( + self, + hidden_dim: int, + ctx_dim: int, + num_heads: int, + mlp_ratio: float = 4.0, + ): + super().__init__() + if hidden_dim % num_heads != 0: + raise ValueError( + f"hidden_dim ({hidden_dim}) must be divisible by " + f"num_heads ({num_heads})" + ) + self.num_heads = num_heads + self.head_dim = hidden_dim // num_heads + self.scale = self.head_dim**-0.5 + + self.norm_q = nn.LayerNorm(hidden_dim) + self.norm_kv = nn.LayerNorm(ctx_dim) + + self.q_proj = nn.Linear(hidden_dim, hidden_dim, bias=True) + self.kv_proj = nn.Linear(ctx_dim, 2 * hidden_dim, bias=True) + self.out_proj = nn.Linear(hidden_dim, hidden_dim, bias=True) + + self.attn_gate = nn.Parameter(torch.zeros(1)) + + mlp_hidden = _swiglu_width(hidden_dim, mlp_ratio, 64) + self.norm_ffn = nn.LayerNorm(hidden_dim) + self.ffn = SwiGLU(hidden_dim, mlp_hidden) + self.ffn_gate = nn.Parameter(torch.zeros(1)) + + self._init_weights() + + def _init_weights(self): + for module in self.modules(): + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.constant_(module.bias, 0) + nn.init.zeros_(self.attn_gate) + nn.init.zeros_(self.ffn_gate) + + def forward( + self, + x: torch.Tensor, + ctx: torch.Tensor, + ctx_mask: torch.Tensor | None, + attn_bias: torch.Tensor | None, + query_mask: torch.Tensor | None, + ) -> torch.Tensor: + """ + Args: + x: (B, N, d) atom features (queries). + ctx: (B, K, d_ctx) conditioning tokens. + ctx_mask: (B, K) bool, True for valid tokens. ``None`` attends to all. + attn_bias: additive bias on attention logits, or ``None``. Either + (B, K, N) shared across heads or (B, H, K, N) per head. + query_mask: (B, N) bool, True for valid atoms. Used to zero out + cross-attention output at padded query positions. + + Returns: + (B, N, d) updated features. + """ + B, N, _ = x.shape + K = ctx.shape[1] + H, Dh = self.num_heads, self.head_dim + + # A padded mixed-context batch can contain K=0 examples alongside + # conditioned examples. Those rows must be an exact identity through + # BOTH branches of this adapter, matching an all-K=0 batch where the + # cross-attention port is skipped altogether. + if K == 0: + return x + has_context = None + safe_ctx_mask = ctx_mask + if ctx_mask is not None: + has_context = ctx_mask.any(dim=-1) + if bool((~has_context).any()): + # Avoid an all--inf SDPA row. Its arbitrary placeholder output is + # removed below together with the independent FFN residual. + safe_ctx_mask = ctx_mask.clone() + safe_ctx_mask[~has_context, 0] = True + + q = self.q_proj(self.norm_q(x)) + kv = self.kv_proj(self.norm_kv(ctx)) + k, v = kv.chunk(2, dim=-1) + + q = q.view(B, N, H, Dh).transpose(1, 2) # (B, H, N, Dh) + k = k.view(B, K, H, Dh).transpose(1, 2) + v = v.view(B, K, H, Dh).transpose(1, 2) + + attn_mask: torch.Tensor | None = None + if attn_bias is not None or ctx_mask is not None: + if attn_bias is not None: + # -> (B, H_or_1, N, K). A 4-D bias is already per-head; a 3-D one + # gets a singleton head axis and broadcasts inside SDPA. + attn_mask = ( + attn_bias.transpose(-1, -2) + if attn_bias.dim() == 4 + else attn_bias.transpose(1, 2).unsqueeze(1) + ).to(q.dtype) + else: + attn_mask = torch.zeros((B, 1, N, K), dtype=q.dtype, device=q.device) + if safe_ctx_mask is not None: + attn_mask = attn_mask.masked_fill( + ~safe_ctx_mask[:, None, None, :], float("-inf") + ) + + out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask) + out = out.transpose(1, 2).contiguous().view(B, N, -1) + out = self.out_proj(out) + + if query_mask is not None: + out = out * query_mask.unsqueeze(-1).to(out.dtype) + if has_context is not None: + out = out * has_context[:, None, None].to(out.dtype) + + x = x + torch.tanh(self.attn_gate) * out + ffn_out = self.ffn(self.norm_ffn(x)) + if has_context is not None: + ffn_out = ffn_out * has_context[:, None, None].to(ffn_out.dtype) + x = x + torch.tanh(self.ffn_gate) * ffn_out + return x + + +# --------------------------------------------------------------------------- +# Backbone (composes a DiTBackbone with optional gated conditioning ports) +# --------------------------------------------------------------------------- + + +class ConditionalDiTBackbone(nn.Module): + """DiT backbone with four opt-in gated conditioning ports (§ module doc). + + Holds a :class:`DiTBackbone` (``self.dit``) plus, when ``use_cross_attn`` is + set, a parallel ``ModuleList`` of :class:`GatedCrossAttention` modules + (``self.cross_attn_blocks``). The forward delegates input projection / final + layer / heads to ``self.dit.encode`` / ``self.dit.decode``, iterates + ``self.dit.blocks``, and interleaves a gated cross-attention block before + each block when the cross-attention port is enabled and ``cond_tokens`` is + supplied. + + Loading a pretrained vanilla DiT is just:: + + model.load_dit_state_dict(dit_backbone_sd) + + which forwards to ``self.dit.load_state_dict`` — the conditioning modules + keep their zero-gated init so the augmented model behaves + identically to the loaded DiT until the gates train away from 0. + """ + + def __init__( + self, + d_input: int, + d_cond: int, + ctx_dim: int, + d_model: int = 512, + out_node_nf: int = 256, + out_edge_nf: int | None = None, + num_layers: int = 12, + nhead: int = 16, + mlp_ratio: float = 4.0, + in_edge_nf: int = 128, + rbf_embedding_args: DictConfig | None = None, + pair_bias_hidden_dim: int = 32, + pair_bias_all_layers: bool | None = None, + pair_bias_mode: str | None = None, + ffn_hidden_dim: int | None = None, + ffn_multiple_of: int = 64, + qk_norm: bool = True, + edge_pair_dim: int | None = None, + coordinate_output_init_std: float = 0.0, + center_coordinate_output: bool = True, + num_atom_types: int | None = None, + use_cross_attn: bool = False, + use_node_cond: bool = False, + use_edge_cond: bool = False, + cross_attn_mlp_ratio: float | None = None, + lora: DictConfig | dict[str, Any] | None = None, + ): + super().__init__() + self.ctx_dim = ctx_dim + self.in_edge_nf = in_edge_nf + self.d_model = d_model + # Kept so wrappers building a per-head cross-attn bias know the head count. + self.nhead = nhead + self.use_cross_attn = use_cross_attn + self.use_node_cond = use_node_cond + self.use_edge_cond = use_edge_cond + self.num_layers = num_layers + + self.dit = DiTBackbone( + d_input=d_input, + d_cond=d_cond, + d_model=d_model, + out_node_nf=out_node_nf, + out_edge_nf=out_edge_nf, + num_layers=num_layers, + nhead=nhead, + mlp_ratio=mlp_ratio, + in_edge_nf=in_edge_nf, + rbf_embedding_args=rbf_embedding_args, + pair_bias_hidden_dim=pair_bias_hidden_dim, + pair_bias_all_layers=pair_bias_all_layers, + pair_bias_mode=pair_bias_mode, + ffn_hidden_dim=ffn_hidden_dim, + ffn_multiple_of=ffn_multiple_of, + qk_norm=qk_norm, + edge_pair_dim=edge_pair_dim, + coordinate_output_init_std=coordinate_output_init_std, + center_coordinate_output=center_coordinate_output, + num_atom_types=num_atom_types, + lora=lora, + ) + + # Cross-attention (sequence / cond_tokens) port. Built only when + # enabled — the per-block blocks are the heaviest conditioning params, + # and leaving them unused would trip ddp_find_unused_parameters_false. + # Per-block cross-attention FFN dominates trainable-param count (~76% of + # a block at mlp_ratio=4). Expose its width independently of the DiT + # body's mlp_ratio (which must stay tied to the loaded pretrained + # checkpoint) so adapter capacity can be tuned cheaply. ``None`` falls + # back to ``mlp_ratio``. + if use_cross_attn: + x_attn_mlp_ratio = ( + mlp_ratio + if cross_attn_mlp_ratio is None + else float(cross_attn_mlp_ratio) + ) + self.cross_attn_blocks = nn.ModuleList( + [ + GatedCrossAttention( + d_model, ctx_dim, nhead, mlp_ratio=x_attn_mlp_ratio + ) + for _ in range(num_layers) + ] + ) + else: + self.cross_attn_blocks = None + + # Embed-outside: conditioning arrives pre-projected, so the backbone + # keeps only the zero-init fusion gate per port (built when enabled so + # unused params never trip ddp_find_unused_parameters_false). + if use_node_cond: + self.node_gate = nn.Parameter(torch.zeros(1)) + else: + self.register_parameter("node_gate", None) + + if use_edge_cond: + self.edge_cond_gate = nn.Parameter(torch.zeros(1)) + else: + self.register_parameter("edge_cond_gate", None) + + @property + def pos_out(self) -> nn.Module: + """Expose the DiT coordinate head without registering a second copy.""" + return self.dit.pos_out + + def forward( + self, + a_embed: torch.Tensor, + x: torch.Tensor, + cond: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + batch: torch.Tensor, + max_seqlen: int, + cond_tokens: torch.Tensor | None = None, + cond_token_mask: torch.Tensor | None = None, + cond_token_attn_bias: torch.Tensor | None = None, + node_cond: torch.Tensor | None = None, + edge_cond: torch.Tensor | None = None, + atom_types: torch.Tensor | None = None, + node_counts: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Same as :meth:`DiTBackbone.forward` plus the cross-attention / node / + edge conditioning kwargs. All conditioning arrives pre-embedded + at its port's fusion width (see the module docstring); each port is + skipped when its kwarg is ``None``. + + Args: + a_embed: (N, d_input) embedded atom features. + x: (N, 3) normalized input positions. + cond: (B, d_cond) graph conditioning features. + edge_index: Two (E,) tensors of directed source/destination indices. + e_embed: (E, in_edge_nf) embedded bond features. + batch: (N,) graph indices for packed nodes. + max_seqlen: Largest graph size, resolved before entering compilation. + atom_types: Optional (N,) atom indices for pair-type attention bias. + node_counts: Optional (B,) cached graph sizes. + cond_tokens: (B, K, ctx_dim) external context tokens the atoms + attend to. If ``None`` the gated cross-attention blocks are + skipped (cheaper and equivalent to gate=0). + cond_token_mask: (B, K) bool, True at valid tokens. + cond_token_attn_bias: additive bias on cross-attention logits, either + (B, K, N_padded) shared across heads or (B, nhead, K, N_padded) + per head. ``N_padded`` is the max number of atoms across the + current batch as produced by ``to_dense_batch``. + node_cond: (N, d_model) per-atom conditioning aligned 1-to-1 with + the denoised atoms (ragged batch order), added to ``h`` through a + zero-init gate. Requires ``use_node_cond=True``. + edge_cond: (E, in_edge_nf) per-edge conditioning features aligned + 1-to-1 with ``edge_index`` / ``e_embed``, added through a + zero-init gate. Requires ``use_edge_cond=True``. + See ``DiTBackbone.forward`` for the remaining arguments. + """ + if node_counts is None: + node_counts = torch.bincount(batch, minlength=cond.shape[0]) + h, cu_seqlens, c = self.dit.encode(a_embed, x, cond, batch, node_counts) + triu_edge_positions, full_to_triu = batched_triu_edge_layout( + edge_index, batch, node_counts + ) + + if node_cond is not None: + if self.node_gate is None: + raise ValueError( + "node_cond was provided but the backbone was constructed " + "with use_node_cond=False. Set use_node_cond=True to enable " + "per-node conditioning." + ) + if node_cond.shape != h.shape: + raise ValueError( + f"node_cond shape {tuple(node_cond.shape)} != node feature " + f"shape {tuple(h.shape)} (N, d_model). node_cond must arrive " + "pre-embedded and aligned 1-to-1 with the atoms." + ) + h = h + torch.tanh(self.node_gate) * node_cond.to(dtype=h.dtype) + + if cond_tokens is not None: + if self.cross_attn_blocks is None: + raise ValueError( + "cond_tokens was provided but the backbone was constructed " + "with use_cross_attn=False. Set use_cross_attn=True to enable " + "the cross-attention port." + ) + if ( + cond_token_attn_bias is not None + and cond_token_attn_bias.shape[-1] != max_seqlen + ): + raise ValueError( + "cond_token_attn_bias last dim " + f"{cond_token_attn_bias.shape[-1]} != padded num atoms " + f"{max_seqlen}. Bias must match the padded length produced " + "by to_dense_batch." + ) + + batch_size = cond.shape[0] + do_cross_attn = self.cross_attn_blocks is not None and cond_tokens is not None + # One cached flat<->dense scatter index for all attention layers (the + # cross-attn query padding here + the self-attn padding inside each + # DiTBlock). Reused instead of recomputing to_dense_batch per layer. + dense_slot = ( + compute_dense_index(batch, batch_size, max_seqlen, node_counts) + if _dit.USE_DENSE_INDEX + else None + ) + pair_bias, atom_mask, distance_embed = self.dit.pair_bias( + x, + edge_index, + e_embed, + batch, + batch_size, + max_seqlen, + atom_types=atom_types, + lengths=node_counts, + dense_slot=dense_slot, + triu_edge_positions=triu_edge_positions, + ) + + for i, block in enumerate(self.dit.blocks): + if do_cross_attn: + if dense_slot is not None: + h_dense = pad_dense(h, dense_slot, batch_size, max_seqlen) + else: + h_dense, _ = to_dense_batch( + h, batch, batch_size=batch_size, max_num_nodes=max_seqlen + ) + h_dense = self.cross_attn_blocks[i]( + h_dense, + cond_tokens, + cond_token_mask, + cond_token_attn_bias, + query_mask=atom_mask, + ) + h = ( + unpad_dense(h_dense, dense_slot) + if dense_slot is not None + else h_dense[atom_mask] + ) + + h = block( + h, + c, + batch, + cu_seqlens, + max_seqlen, + pair_bias=self.dit.pair_bias_for_layer(pair_bias, i), + atom_mask=atom_mask, + dense_slot=dense_slot, + ) + + if edge_cond is not None: + if self.edge_cond_gate is None: + raise ValueError( + "edge_cond was provided but the backbone was constructed " + "with use_edge_cond=False. Set use_edge_cond=True to enable " + "per-edge conditioning." + ) + if edge_cond.shape[0] != e_embed.shape[0]: + raise ValueError( + f"edge_cond first dim {edge_cond.shape[0]} != number of " + f"edges {e_embed.shape[0]}; edge_cond must be aligned to " + "edge_index / e_embed." + ) + if edge_cond.shape[-1] != self.in_edge_nf: + raise ValueError( + f"edge_cond last dim {edge_cond.shape[-1]} != backbone " + f"in_edge_nf {self.in_edge_nf}. edge_cond must arrive " + "pre-embedded." + ) + e_embed = e_embed + torch.tanh(self.edge_cond_gate) * edge_cond + + triu_edge_index = ( + edge_index[0][triu_edge_positions], + edge_index[1][triu_edge_positions], + ) + outputs = self.dit.decode( + h, + c, + batch, + x, + triu_edge_index, + e_embed[triu_edge_positions], + distance_embed, + ) + return *outputs, full_to_triu + + def load_dit_state_dict( + self, + dit_state_dict: dict[str, torch.Tensor], + strict: bool = True, + ) -> Any: + """Load a vanilla ``DiTBackbone`` state_dict into ``self.dit``. + + Cross-attention blocks are unaffected and keep their zero-gated init. + """ + return self.dit.load_state_dict(dit_state_dict, strict=strict) + + +# --------------------------------------------------------------------------- +# Top-level wrapper (extends DiTBackboneWithHeads) +# --------------------------------------------------------------------------- + + +class ConditionalDiTBackboneWithHeads(DiTBackboneWithHeads): + """:class:`DiTBackboneWithHeads` with cross-attention forward kwargs. + + Constructor is identical to the parent — set ``backbone_model_args._target_`` + to ``morph.model.backbones.conditional_dit.ConditionalDiTBackbone`` to swap + the backbone in via Hydra. The forward simply forwards the new kwargs into + ``self.backbone``. + """ + + def freeze_pretrained(self) -> int: + """Freeze every submodule whose weights load from the unconditional + pretrained checkpoint. + + On a freshly-loaded checkpoint this leaves trainable only the + modules introduced by ``ConditionalDiTBackbone`` itself: the gated + cross-attention blocks and, when present, the per-node and per-edge + conditioning streams. Subclasses can extend the freeze policy to their + own auxiliary modules. + """ + before = sum(p.numel() for p in self.parameters() if p.requires_grad) + # Clean-lane modules loaded from the pretrained checkpoint: + self.embedding_backbone.requires_grad_(False) + self.backbone.dit.requires_grad_(False) + # LoRA is part of the clean lane structurally, but it is the trainable + # fine-tuning delta rather than a pretrained parameter. + self.backbone.dit.set_lora_trainable() + self.heads.requires_grad_(False) + self.insertion_head.requires_grad_(False) + self.ins_edge_head.requires_grad_(False) + after = sum(p.numel() for p in self.parameters() if p.requires_grad) + return before - after + + def forward( + self, + mols_t: ChemBatch, + t: torch.Tensor, + cond_tokens: torch.Tensor | None = None, + cond_token_mask: torch.Tensor | None = None, + cond_token_attn_bias: torch.Tensor | None = None, + node_cond: torch.Tensor | None = None, + edge_cond: torch.Tensor | None = None, + max_seqlen: int | None = None, + ) -> dict[str, Any]: + """Predict clean states and edits using optional context tokens.""" + x, a, _c, _e, edge_index, batch = mols_t.unpack() + # Eager callers (sampling) leave max_seqlen None; the compiled training + # path passes it precomputed to keep the graph host-read-free. + if max_seqlen is None: + max_seqlen = mols_t.max_seqlen + + a_embed, e_embed, cond = self.embedding_backbone(mols_t, t) + + edge_index_tuple = (edge_index[0], edge_index[1]) + + h, x_out, e_out_triu, full_to_triu = self.backbone( + a_embed, + x, + cond, + edge_index_tuple, + e_embed, + batch, + max_seqlen, + cond_tokens=cond_tokens, + cond_token_mask=cond_token_mask, + cond_token_attn_bias=cond_token_attn_bias, + node_cond=node_cond, + edge_cond=edge_cond, + atom_types=a, + node_counts=mols_t.num_nodes_per_graph, + ) + + return self._assemble_outputs( + h, + x_out, + e_out_triu, + full_to_triu, + x, + batch, + edge_index_tuple, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/dit.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/dit.py new file mode 100644 index 0000000000000000000000000000000000000000..379cf9e212155dfb7e05693cda989dbfd746887b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/backbones/dit.py @@ -0,0 +1,841 @@ +"""The molecular Diffusion Transformer. + +This is the repository's sole first-class generative backbone. It combines +adaLN-Zero conditioning, equal-budget SwiGLU feed-forwards, QK normalization, +pair-biased attention, exactly symmetric undirected-edge decoding, +and residual graph-centred coordinate prediction. +""" + +from __future__ import annotations + +import math +import os +from typing import TYPE_CHECKING, Any + +import hydra +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import scatter + +from morph.model.layers import lora as lora_layers +from morph.model.layers.attention import ( + PairBias, + compute_dense_index, + packed_self_attention, +) +from morph.model.layers.heads import InsertionEdgePredictorMixin +from morph.model.layers.kernels import graph_broadcast, normalized_edge_features +from morph.model.layers.lora import LoRALinear +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.atomic_data import ChemBatch + +USE_DENSE_INDEX = os.environ.get("MORPH_DENSE_INDEX", "1") == "1" + + +def modulate( + x: torch.Tensor, + shift: torch.Tensor, + scale: torch.Tensor, + batch_idx: torch.Tensor, + offsets: torch.Tensor | None = None, +) -> torch.Tensor: + """Apply graph-level adaptive modulation to packed node features.""" + return x * (1 + graph_broadcast(scale, batch_idx, offsets)) + graph_broadcast( + shift, batch_idx, offsets + ) + + +class FinalLayer(nn.Module): + """Final adaLN-conditioned projection in packed layout.""" + + def __init__(self, hidden_dim: int, out_dim: int): + super().__init__() + self.norm_final = nn.LayerNorm(hidden_dim, elementwise_affine=False, eps=1e-6) + self.linear = nn.Linear(hidden_dim, out_dim) + self.adaLN_modulation = nn.Sequential( + nn.SiLU(), nn.Linear(hidden_dim, 2 * hidden_dim) + ) + + def forward( + self, x: torch.Tensor, c: torch.Tensor, batch_idx: torch.Tensor + ) -> torch.Tensor: + """Modulate normalized node features with graph-level conditioning.""" + shift, scale = self.adaLN_modulation(c).chunk(2, dim=1) + return self.linear(modulate(self.norm_final(x), shift, scale, batch_idx)) + + +def _swiglu_width(hidden_dim: int, mlp_ratio: float, multiple_of: int) -> int: + """Match the parameter budget of a GELU FFN with the given ratio.""" + if mlp_ratio <= 0: + raise ValueError(f"mlp_ratio must be positive, got {mlp_ratio}") + if multiple_of <= 0: + raise ValueError(f"multiple_of must be positive, got {multiple_of}") + target = 2.0 * hidden_dim * mlp_ratio / 3.0 + return max(multiple_of, round(target / multiple_of) * multiple_of) + + +class SwiGLU(nn.Module): + """Fused SwiGLU feed-forward network.""" + + def __init__(self, in_features: int, hidden_features: int): + super().__init__() + self.in_features = in_features + self.hidden_features = hidden_features + self.gate_value = nn.Linear(in_features, 2 * hidden_features) + self.out_proj = nn.Linear(hidden_features, in_features) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Apply a SiLU-gated feed-forward projection.""" + gate, value = self.gate_value(x).chunk(2, dim=-1) + return self.out_proj(F.silu(gate) * value) + + +class DiTBlock(nn.Module): + """adaLN-Zero block with QK normalization and equal-budget SwiGLU.""" + + def __init__( + self, + hidden_dim: int, + num_heads: int, + mlp_ratio: float = 4.0, + ffn_hidden_dim: int | None = None, + ffn_multiple_of: int = 64, + qk_norm: bool = True, + ): + super().__init__() + if hidden_dim % num_heads != 0: + raise ValueError( + f"hidden_dim ({hidden_dim}) must be divisible by " + f"num_heads ({num_heads})" + ) + self.hidden_dim = hidden_dim + self.num_heads = num_heads + self.head_dim = hidden_dim // num_heads + + self.norm1 = nn.LayerNorm(hidden_dim, elementwise_affine=False, eps=1e-6) + self.qkv = nn.Linear(hidden_dim, 3 * hidden_dim) + self.q_norm = nn.RMSNorm(self.head_dim, eps=1e-6) if qk_norm else nn.Identity() + self.k_norm = nn.RMSNorm(self.head_dim, eps=1e-6) if qk_norm else nn.Identity() + self.proj = nn.Linear(hidden_dim, hidden_dim) + + self.norm2 = nn.LayerNorm(hidden_dim, elementwise_affine=False, eps=1e-6) + if ffn_hidden_dim is None: + ffn_hidden_dim = _swiglu_width(hidden_dim, mlp_ratio, ffn_multiple_of) + if ffn_hidden_dim <= 0: + raise ValueError(f"ffn_hidden_dim must be positive, got {ffn_hidden_dim}") + self.mlp = SwiGLU(hidden_dim, ffn_hidden_dim) + self.adaLN_modulation = nn.Sequential( + nn.SiLU(), nn.Linear(hidden_dim, 6 * hidden_dim) + ) + + def forward( + self, + x: torch.Tensor, + c: torch.Tensor, + batch_idx: torch.Tensor, + cu_seqlens: torch.Tensor, + max_seqlen: int, + pair_bias: torch.Tensor | None = None, + atom_mask: torch.Tensor | None = None, + dense_slot: torch.Tensor | None = None, + ) -> torch.Tensor: + """Apply conditioned packed attention and gated feed-forward updates.""" + shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = ( + self.adaLN_modulation(c).chunk(6, dim=1) + ) + + x_mod = modulate(self.norm1(x), shift_msa, scale_msa, batch_idx, cu_seqlens) + qkv = self.qkv(x_mod).view(-1, 3, self.num_heads, self.head_dim) + attn_out = packed_self_attention( + qkv, + cu_seqlens, + max_seqlen, + batch_idx, + pair_bias=pair_bias, + atom_mask=atom_mask, + dense_slot=dense_slot, + q_norm=self.q_norm, + k_norm=self.k_norm, + ) + attn_out = self.proj(attn_out.reshape(-1, self.hidden_dim)) + + x = x + graph_broadcast(gate_msa, batch_idx, cu_seqlens) * attn_out + x = x + graph_broadcast(gate_mlp, batch_idx, cu_seqlens) * self.mlp( + modulate(self.norm2(x), shift_mlp, scale_mlp, batch_idx, cu_seqlens) + ) + return x + + +class SymmetricEdgeDecoder(nn.Module): + """Decode undirected edges from a compact DeepSets-style pair feature.""" + + def __init__( + self, + node_dim: int, + edge_dim: int, + distance_dim: int, + output_dim: int, + pair_dim: int, + ): + super().__init__() + self.node_proj = nn.Sequential( + nn.LayerNorm(node_dim), + nn.Linear(node_dim, pair_dim), + nn.SiLU(), + ) + input_dim = 2 * pair_dim + edge_dim + distance_dim + self.output = nn.Sequential( + nn.LayerNorm(input_dim), + nn.Linear(input_dim, output_dim), + nn.LayerNorm(output_dim), + nn.GELU(), + ) + + def forward( + self, + h: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + edge_embed: torch.Tensor, + distance_embed: torch.Tensor, + ) -> torch.Tensor: + """Decode symmetric edge features from nodes, bonds, and distances.""" + rows, cols = edge_index + p = self.node_proj(h) + normalized = normalized_edge_features( + p, edge_index, edge_embed, distance_embed, self.output[0] + ) + if normalized is not None: + return self.output[3](self.output[2](self.output[1](normalized))) + p_i, p_j = p[rows], p[cols] + scale = math.sqrt(0.5) + pair_sum = scale * (p_i + p_j) + pair_difference = scale * (p_i - p_j).abs() + del p_i, p_j + return self.output( + torch.cat([pair_sum, pair_difference, edge_embed, distance_embed], dim=-1) + ) + + +class ResidualCoordinateReadout(nn.Module): + """Predict residual clean coordinates and optionally project out graph COM.""" + + def __init__( + self, + input_dim: int, + hidden_dim: int, + output_init_std: float = 0.0, + center_output: bool = True, + ): + super().__init__() + if output_init_std < 0: + raise ValueError( + f"output_init_std must be non-negative, got {output_init_std}" + ) + self.center_output = center_output + self.norm = nn.RMSNorm(input_dim, eps=1e-6) + self.hidden = nn.Linear(input_dim, hidden_dim) + self.output = nn.Linear(hidden_dim, 3) + nn.init.xavier_uniform_(self.hidden.weight) + nn.init.zeros_(self.hidden.bias) + if output_init_std == 0: + nn.init.zeros_(self.output.weight) + else: + nn.init.normal_(self.output.weight, mean=0.0, std=output_init_std) + nn.init.zeros_(self.output.bias) + + def forward( + self, + h: torch.Tensor, + x_t: torch.Tensor, + batch: torch.Tensor, + num_graphs: int, + ) -> torch.Tensor: + """Predict clean coordinates with optional graphwise centering.""" + delta = self.output(F.silu(self.hidden(self.norm(h)))) + x_1 = x_t + delta + if not self.center_output: + return x_1 + if num_graphs == 1: + # Inductor 2.9 can miscompile scatter-mean with one output graph. + return x_1 - x_1.mean(dim=0, keepdim=True) + center = scatter(x_1, batch, dim=0, dim_size=num_graphs, reduce="mean") + return x_1 - center[batch] + + +class DiTBackbone(nn.Module): + """Scalar DiT trunk with learned geometry scaling in every layer/head.""" + + LORA_TARGETS = ("qkv", "proj", "mlp.gate_value", "mlp.out_proj") + + def __init__( + self, + d_input: int, + d_cond: int, + d_model: int = 512, + out_node_nf: int = 256, + out_edge_nf: int | None = None, + num_layers: int = 12, + nhead: int = 16, + mlp_ratio: float = 4.0, + in_edge_nf: int = 128, + rbf_embedding_args: DictConfig | None = None, + pair_bias_hidden_dim: int = 32, + pair_bias_all_layers: bool | None = None, + pair_bias_mode: str | None = None, + ffn_hidden_dim: int | None = None, + ffn_multiple_of: int = 64, + qk_norm: bool = True, + edge_pair_dim: int | None = None, + coordinate_output_init_std: float = 0.0, + center_coordinate_output: bool = True, + num_atom_types: int | None = None, + lora: DictConfig | dict[str, Any] | None = None, + ): + super().__init__() + if num_layers <= 0: + raise ValueError(f"num_layers must be positive, got {num_layers}") + if rbf_embedding_args is None: + raise ValueError("rbf_embedding_args is required") + + self.d_model = d_model + self.out_node_nf = out_node_nf + self.out_edge_nf = out_node_nf if out_edge_nf is None else out_edge_nf + # These two arguments only remain for saved checkpoint configurations. + # New presets omit both and always use scaling in every layer. Older + # shared-bias configs supplied all_layers but predate the mode field. + if pair_bias_mode is None: + pair_bias_mode = "scale" if pair_bias_all_layers is None else "shared" + self.pair_bias_all_layers = ( + True if pair_bias_all_layers is None else bool(pair_bias_all_layers) + ) + if pair_bias_mode not in {"shared", "scale"}: + raise ValueError( + f"Pair-bias sweep mode {pair_bias_mode!r} has been retired; " + "use its archived training source to load that checkpoint" + ) + if pair_bias_mode != "shared" and not self.pair_bias_all_layers: + raise ValueError( + "Layer-specific pair bias requires pair_bias_all_layers=True" + ) + self.pair_bias_mode = pair_bias_mode + if self.out_edge_nf <= 0: + raise ValueError(f"out_edge_nf must be positive, got {self.out_edge_nf}") + self.input_proj = nn.Sequential( + nn.Linear(d_input, d_model), + nn.SiLU(), + nn.Linear(d_model, d_model), + ) + self.pos_embedding = nn.Sequential( + nn.Linear(3, d_model, bias=False), + nn.SiLU(), + nn.Linear(d_model, d_model), + ) + self.cond_proj = nn.Sequential( + nn.Linear(d_cond, d_model), + nn.SiLU(), + nn.Linear(d_model, d_model), + ) + self.blocks = nn.ModuleList( + [ + DiTBlock( + d_model, + nhead, + mlp_ratio=mlp_ratio, + ffn_hidden_dim=ffn_hidden_dim, + ffn_multiple_of=ffn_multiple_of, + qk_norm=qk_norm, + ) + for _ in range(num_layers) + ] + ) + + self.final_layer = FinalLayer(d_model, d_model) + self.node_out = ( + nn.Identity() + if out_node_nf == d_model + else nn.Sequential( + nn.Linear(d_model, out_node_nf), + nn.SiLU(), + nn.Linear(out_node_nf, out_node_nf), + ) + ) + self.rbf_embedding = hydra.utils.instantiate(rbf_embedding_args) + self._rbf_has_dropout = any( + isinstance(module, nn.Dropout) and module.p > 0 + for module in self.rbf_embedding.modules() + ) + rbf_out_dim = rbf_embedding_args.get( + "out_dim", rbf_embedding_args.get("num_rbf", 16) + ) + self.pair_bias = PairBias( + rbf_embedding=self.rbf_embedding, + rbf_out_dim=rbf_out_dim, + edge_nf=in_edge_nf, + num_heads=nhead, + hidden_dim=pair_bias_hidden_dim, + num_atom_types=num_atom_types, + ) + if edge_pair_dim is None: + edge_pair_dim = min(out_node_nf, 256) + if edge_pair_dim <= 0: + raise ValueError(f"edge_pair_dim must be positive, got {edge_pair_dim}") + self.edge_output = SymmetricEdgeDecoder( + node_dim=out_node_nf, + edge_dim=in_edge_nf, + distance_dim=rbf_out_dim, + output_dim=self.out_edge_nf, + pair_dim=edge_pair_dim, + ) + + self._initialize_weights() + # FinalLayer produces a shared latent, not the task prediction itself. + nn.init.xavier_uniform_(self.final_layer.linear.weight) + nn.init.zeros_(self.final_layer.linear.bias) + # Build this after the generic initialization so its deliberately zero + # task projection is preserved at initialization. + self.pos_out = ResidualCoordinateReadout( + input_dim=out_node_nf, + hidden_dim=d_model, + output_init_std=coordinate_output_init_std, + center_output=center_coordinate_output, + ) + # Keep parameter names and initialization compatible with the selected + # scaling checkpoint and preserve the common model's RNG state. + if pair_bias_mode == "scale": + for block in self.blocks: + block.geometry_scale = nn.Parameter(torch.ones(nhead)) + if lora is not None and bool(lora.get("enabled", False)): + rank = int(lora.get("rank", 8)) + self.enable_lora( + rank=rank, + alpha=float(lora.get("alpha", rank)), + dropout=float(lora.get("dropout", 0.0)), + targets=lora.get("targets", self.LORA_TARGETS), + freeze_base=bool(lora.get("freeze_base", True)), + ) + + def pair_bias_for_layer(self, pair_bias, index): + """Select a layer's bias while retaining shared geometry computation.""" + if not self.pair_bias_all_layers and index > 0: + return None + block = self.blocks[index] + if self.pair_bias_mode == "scale": + # Multiplying a padded -inf by a learnable factor gives NaN factor + # gradients; scale finite entries and restore padding afterwards. + valid = torch.isfinite(pair_bias) + scaled = pair_bias.masked_fill(~valid, 0) * block.geometry_scale.to( + pair_bias.dtype + ).view(1, -1, 1, 1) + return scaled.masked_fill(~valid, -torch.inf) + return pair_bias + + def enable_lora( + self, + *, + rank: int = 8, + alpha: float | None = None, + dropout: float = 0.0, + targets: tuple[str, ...] | list[str] = LORA_TARGETS, + freeze_base: bool = True, + ) -> int: + """Add LoRA to selected block projections. + + Targets are paths relative to every :class:`DiTBlock`. With + ``freeze_base=True``, only the low-rank matrices remain trainable inside + this backbone. Returns their total parameter count. + """ + if any(lora_layers.iter_lora_layers(self)): + raise RuntimeError("LoRA is already enabled on this DiT backbone") + if isinstance(targets, str): + targets = (targets,) + targets = tuple(targets) + if not targets: + raise ValueError("At least one LoRA target is required") + if len(set(targets)) != len(targets): + raise ValueError(f"LoRA targets must be unique, got {targets}") + unknown = sorted(set(targets) - set(self.LORA_TARGETS)) + if unknown: + raise ValueError( + f"Unknown LoRA targets {unknown}; choose from {self.LORA_TARGETS}" + ) + alpha = float(rank if alpha is None else alpha) + if rank <= 0: + raise ValueError(f"LoRA rank must be positive, got {rank}") + if alpha <= 0: + raise ValueError(f"LoRA alpha must be positive, got {alpha}") + if not 0.0 <= dropout < 1.0: + raise ValueError(f"LoRA dropout must be in [0, 1), got {dropout}") + if freeze_base: + self.requires_grad_(False) + + for block in self.blocks: + modules = { + "qkv": (block, "qkv"), + "proj": (block, "proj"), + "mlp.gate_value": (block.mlp, "gate_value"), + "mlp.out_proj": (block.mlp, "out_proj"), + } + for target in targets: + parent, name = modules[target] + linear = getattr(parent, name) + setattr( + parent, + name, + LoRALinear.from_linear( + linear, + rank=rank, + alpha=alpha, + dropout=dropout, + freeze_base=freeze_base, + ), + ) + return lora_layers.set_lora_trainable(self) + + @property + def has_lora(self) -> bool: + """Whether this backbone contains low-rank adapters.""" + return any(lora_layers.iter_lora_layers(self)) + + def set_lora_trainable(self, trainable: bool = True) -> int: + """Toggle gradients for every adapter and return its parameter count.""" + return lora_layers.set_lora_trainable(self, trainable) + + def merge_lora_(self) -> DiTBackbone: + """Fold all adapters into their base projections for inference.""" + lora_layers.merge_lora_(self) + return self + + def unmerge_lora_(self) -> DiTBackbone: + """Restore the separate adapter projections used for training.""" + lora_layers.unmerge_lora_(self) + return self + + def train(self, mode: bool = True) -> DiTBackbone: + """Set training mode, unmerging adapters before any optimization.""" + # Never optimize a base weight that already contains its adapter delta. + if mode: + self.unmerge_lora_() + return super().train(mode) + + def _initialize_weights(self) -> None: + def _basic_init(module: nn.Module) -> None: + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.zeros_(module.bias) + + self.apply(_basic_init) + for block in self.blocks: + nn.init.zeros_(block.adaLN_modulation[-1].weight) + nn.init.zeros_(block.adaLN_modulation[-1].bias) + + nn.init.zeros_(self.final_layer.adaLN_modulation[-1].weight) + nn.init.zeros_(self.final_layer.adaLN_modulation[-1].bias) + nn.init.zeros_(self.final_layer.linear.weight) + nn.init.zeros_(self.final_layer.linear.bias) + + # Keep PairBias non-trivial once adaLN-Zero opens the attention residual. + nn.init.xavier_uniform_(self.pair_bias.down_proj.weight) + + def encode( + self, + a_embed: torch.Tensor, + x: torch.Tensor, + cond: torch.Tensor, + batch: torch.Tensor, + node_counts: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Project packed node inputs and graph conditioning.""" + batch_size = cond.shape[0] + h = self.input_proj(a_embed) + self.pos_embedding(x) + c = self.cond_proj(cond) + if node_counts is None: + node_counts = torch.bincount(batch, minlength=batch_size) + cu_seqlens = F.pad(node_counts.cumsum(dim=0), (1, 0)).to(torch.int32) + return h, cu_seqlens, c + + def decode( + self, + h: torch.Tensor, + c: torch.Tensor, + batch_idx: torch.Tensor, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + distance_embed: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Apply the node, position, and undirected-edge output projections.""" + h = self.node_out(self.final_layer(h, c, batch_idx)) + # Preserve independent dropout draws for custom stochastic RBF configs. + # Shipped configs use dropout=0 and take the one-pass reuse path. + if self.training and self._rbf_has_dropout: + rows, cols = edge_index + distance_embed = self.rbf_embedding((x[rows] - x[cols]).norm(dim=1)) + e_out = self.edge_output(h, edge_index, e_embed, distance_embed) + return h, self.pos_out(h, x, batch_idx, c.shape[0]), e_out + + def forward( + self, + a_embed: torch.Tensor, + x: torch.Tensor, + cond: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + batch: torch.Tensor, + max_seqlen: int, + atom_types: torch.Tensor | None = None, + node_counts: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Run the packed DiT and return node, position, and triu edge features.""" + if node_counts is None: + node_counts = torch.bincount(batch, minlength=cond.shape[0]) + h, cu_seqlens, c = self.encode(a_embed, x, cond, batch, node_counts) + batch_size = cond.shape[0] + triu_edge_positions, full_to_triu = batched_triu_edge_layout( + edge_index, batch, node_counts + ) + dense_slot = ( + compute_dense_index(batch, batch_size, max_seqlen, node_counts) + if USE_DENSE_INDEX + else None + ) + pair_bias, atom_mask, distance_embed = self.pair_bias( + x, + edge_index, + e_embed, + batch, + batch_size, + max_seqlen, + atom_types=atom_types, + lengths=node_counts, + dense_slot=dense_slot, + triu_edge_positions=triu_edge_positions, + ) + + for idx, block in enumerate(self.blocks): + h = block( + h, + c, + batch, + cu_seqlens, + max_seqlen, + pair_bias=self.pair_bias_for_layer(pair_bias, idx), + atom_mask=atom_mask, + dense_slot=dense_slot, + ) + + triu_edge_index = ( + edge_index[0][triu_edge_positions], + edge_index[1][triu_edge_positions], + ) + outputs = self.decode( + h, + c, + batch, + x, + triu_edge_index, + e_embed[triu_edge_positions], + distance_embed, + ) + return *outputs, full_to_triu + + +# --------------------------------------------------------------------------- +# Embedding and complete model +# --------------------------------------------------------------------------- + + +class DiTEmbedding(nn.Module): + """Embedding module for DiT. + + Produces atom embeddings, edge embeddings, and a *graph-level* conditioning + vector (time + node count). + + Conditioning remains graph-level so ``DiTBackbone`` can feed it through + adaLN without duplicating it across packed nodes. + """ + + def __init__( + self, + atom_type_embedding_args: DictConfig, + edge_type_embedding_args: DictConfig, + time_embedding_args: DictConfig, + node_count_embedding_args: DictConfig, + incident_bond_embedding: bool = False, + ): + super().__init__() + self.atom_type_embedding = hydra.utils.instantiate(atom_type_embedding_args) + self.edge_type_embedding = hydra.utils.instantiate(edge_type_embedding_args) + self.time_embedding = hydra.utils.instantiate(time_embedding_args) + self.node_count_embedding = hydra.utils.instantiate(node_count_embedding_args) + self.incident_bond_projection = None + if incident_bond_embedding: + if int(edge_type_embedding_args.num_embeddings) < 2: + raise ValueError("Incident-bond embeddings require bond tokens") + atom_dim = ( + atom_type_embedding_args.get("out_dim") + or atom_type_embedding_args.embedding_dim + ) + edge_dim = ( + edge_type_embedding_args.get("out_dim") + or edge_type_embedding_args.embedding_dim + ) + self.incident_bond_projection = nn.Linear( + int(edge_dim), int(atom_dim), bias=False + ) + nn.init.zeros_(self.incident_bond_projection.weight) + + def forward( + self, + mols: ChemBatch, + t: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Returns: + a_embed: (N, atom_dim) atom embeddings (per-node) + e_embed: (E, edge_dim) edge embeddings (per-edge) + cond: (B, cond_dim) graph-level conditioning vector + """ + a, e = mols.a, mols.e + + a_embed = self.atom_type_embedding(a) + e_embed = self.edge_type_embedding(e) + if self.incident_bond_projection is not None: + # Reuse the same edge embeddings supplied to attention and the + # pair decoder. NO_BOND contributes nothing to the incident sum. + incident = e_embed.new_zeros((a.shape[0], e_embed.shape[-1])) + bonded_embeddings = e_embed * (e != 0).unsqueeze(-1) + # Edges are directed and symmetric: summing each row counts every + # neighbour once at its receiving node. Keep sums, not averages. + incident.index_add_(0, mols.edge_index[0], bonded_embeddings) + a_embed = a_embed + self.incident_bond_projection(incident) + + t_embed = self.time_embedding(t) + n_embed = self.node_count_embedding(mols.num_nodes_per_graph) + + cond = torch.cat([t_embed, n_embed], dim=-1) + + return a_embed, e_embed, cond + + +# --------------------------------------------------------------------------- +# Complete model +# --------------------------------------------------------------------------- + + +class DiTBackboneWithHeads(InsertionEdgePredictorMixin, nn.Module): + """Full DiT Model: Embedding -> DiT Backbone -> Prediction Heads. + + This is the public model assembled by ``model=backbones/dit``. + """ + + def __init__( + self, + atom_type_embedding_args: DictConfig, + edge_type_embedding_args: DictConfig, + time_embedding_args: DictConfig, + node_count_embedding_args: DictConfig, + backbone_model_args: DictConfig, + heads_args: DictConfig, + insertion_head_args: DictConfig, + ins_edge_head_args: DictConfig, + incident_bond_embedding: bool = False, + ): + super().__init__() + + self.embedding_backbone = DiTEmbedding( + atom_type_embedding_args=atom_type_embedding_args, + edge_type_embedding_args=edge_type_embedding_args, + time_embedding_args=time_embedding_args, + node_count_embedding_args=node_count_embedding_args, + incident_bond_embedding=incident_bond_embedding, + ) + + self.backbone = hydra.utils.instantiate(backbone_model_args) + self.heads = hydra.utils.instantiate(heads_args) + self.insertion_head = hydra.utils.instantiate(insertion_head_args) + self.ins_edge_head = hydra.utils.instantiate(ins_edge_head_args) + + def freeze_pretrained(self) -> int: + """Freeze the loaded model while leaving DiT LoRA matrices trainable.""" + before = sum(p.numel() for p in self.parameters() if p.requires_grad) + self.requires_grad_(False) + self.backbone.set_lora_trainable() + after = sum(p.numel() for p in self.parameters() if p.requires_grad) + return before - after + + def output_head_modules(self) -> tuple[nn.Module, ...]: + """Return prediction heads, including the backbone's coordinate readout.""" + return ( + self.heads, + self.insertion_head, + self.ins_edge_head, + self.backbone.pos_out, + ) + + def set_output_heads_trainable(self, trainable: bool = True) -> int: + """Set head trainability, returning the number of parameters changed.""" + before = sum(p.numel() for p in self.parameters() if p.requires_grad) + for head in self.output_head_modules(): + head.requires_grad_(trainable) + after = sum(p.numel() for p in self.parameters() if p.requires_grad) + return abs(after - before) + + def initialize_output_priors(self, distributions) -> None: + """Initialize categorical outputs from training-set marginal priors.""" + self.heads.initialize_class_priors( + atom=distributions.atom_type_distribution, + charge=distributions.charge_type_distribution, + edge=distributions.edge_type_distribution, + ) + self.insertion_head.initialize_class_priors( + atom=distributions.atom_type_distribution, + charge=distributions.charge_type_distribution, + ) + self.ins_edge_head.initialize_edge_prior(distributions.edge_type_distribution) + + def forward( + self, + mols_t: ChemBatch, + t: torch.Tensor, + max_seqlen: int | None = None, + ) -> dict[str, Any]: + """Forward pass through Embedding -> DiT Backbone -> Heads.""" + x, a, _c, _e, edge_index, batch = mols_t.unpack() + # max_seqlen must be a precomputed Python int on the compiled (training) + # path: computing it here reads segment_lengths -> .item() + # (aten._local_scalar_dense), which fails torch.compile's backend and drops + # the whole DiT to eager. Eager callers (sampling) leave it None. + if max_seqlen is None: + max_seqlen = mols_t.max_seqlen + + a_embed, e_embed, cond = self.embedding_backbone(mols_t, t) + + edge_index_tuple = (edge_index[0], edge_index[1]) + + h, x_out, e_out_triu, full_to_triu = self.backbone( + a_embed, + x, + cond, + edge_index_tuple, + e_embed, + batch, + max_seqlen, + atom_types=a, + node_counts=mols_t.num_nodes_per_graph, + ) + + return self._assemble_outputs( + h, + x_out, + e_out_triu, + full_to_triu, + x, + batch, + edge_index_tuple, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/build.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/build.py new file mode 100644 index 0000000000000000000000000000000000000000..fa287a60f9118cae9ab03cfeddf44d5f6dfd1229 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/build.py @@ -0,0 +1,92 @@ +"""Shared model construction for training and dataset-free inference.""" + +from copy import deepcopy +from dataclasses import dataclass + +import hydra +from omegaconf import DictConfig, OmegaConf + +from morph.dataset.core.representation import Representation, make_uniform_type_prior +from morph.dataset.core.vocab import Distributions, Vocab, setup_token_weights +from morph.utils.metrics import init_metrics + + +@dataclass(frozen=True) +class ModelData: + """Vocabulary, empirical statistics, and the representation-aware flow prior.""" + + vocab: Vocab + empirical: Distributions + prior: Distributions + representation: Representation + allow_charged: bool + + +def prepare_model_data( + cfg: DictConfig, vocab: Vocab, distributions: Distributions +) -> ModelData: + """Resolve vocabulary config and separate empirical statistics from the prior.""" + OmegaConf.set_struct(cfg, False) + cfg.data.vocab = vocab + representation = Representation(cfg.representation) + return ModelData( + vocab=vocab, + empirical=deepcopy(distributions), + prior=make_uniform_type_prior(distributions, vocab, representation), + representation=representation, + allow_charged=bool(cfg.data.get("allow_charged", False)), + ) + + +def build_model_metrics( + data: ModelData, *, train_smiles=None, train_smiles_are_canonical=False +) -> dict: + """Build matching metric buffers for training and checkpoint loading. + + Dataset-free callers omit training SMILES; novelty has no persistent buffers. + Distribution and geometry buffers must still match the training checkpoint. + """ + distributions, vocab = data.empirical, data.vocab + collections = init_metrics( + train_smiles=train_smiles, + target_n_atoms_distribution=distributions.n_atoms_distribution, + atom_type_distribution=distributions.atom_type_distribution, + edge_type_distribution=distributions.edge_type_distribution, + charge_type_distribution=distributions.charge_type_distribution, + atom_tokens=list(vocab.atom_tokens), + edge_tokens=list(vocab.edge_tokens), + charge_tokens=list(vocab.charge_tokens), + allow_charged=data.allow_charged, + distributions=distributions, + representation=data.representation, + train_smiles_are_canonical=train_smiles_are_canonical, + ) + names = ("metrics", "stability_metrics", "distribution_metrics", "batch_metrics") + return dict(zip(names, collections, strict=True)) + + +def build_model_module( + cfg: DictConfig, data: ModelData, *, metrics: dict | None = None +): + """Construct the configured Lightning module from explicit data and metrics.""" + weighting = cfg.model.token_weighting + atom_weights, edge_weights, charge_weights = setup_token_weights( + vocab=data.vocab, + distributions=data.empirical, + weight_alpha=weighting.weight_alpha, + type_loss_token_weights=weighting.type_loss_token_weights, + ) + if metrics is None: + metrics = build_model_metrics(data) + return hydra.utils.instantiate( + cfg.model.module, + _recursive_=False, + distributions=data.prior, + loss_weight_distributions=data.empirical, + atom_type_weights=atom_weights, + edge_token_weights=edge_weights, + charge_token_weights=charge_weights, + allow_charged=data.allow_charged, + representation=data.representation.value, + **metrics, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..47b5a9eb5d013717c4851897a7d253d4a157279c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/__init__.py @@ -0,0 +1,6 @@ +"""Task-specific fine-tuning, one subpackage per application. + +The ``base`` package contains shared building blocks; ``scaffold`` pairs the +task's Lightning module with its conditional backbone. Public classes are +re-exported at each package boundary for concise Hydra targets. +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..306bf429e184971800658887d2ccac56f97c77a4 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..005c1613e1a4df3198bd5f565e73a0004e9e5c8c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__init__.py @@ -0,0 +1,28 @@ +"""Shared building blocks for conditional fine-tuning tasks.""" + +from morph.model.finetune.base.conditional_backbone import ( + ConditioningDiTBackboneWithHeads, + CondPorts, +) +from morph.model.finetune.base.context_encoder import ( + KeepEdgeCond, + KeepNodeCond, + LightweightContextEncoder, +) +from morph.model.finetune.base.finetune_base import ( + LightningModuleRatesFinetune, + load_pretrained_into_lightning_model, +) +from morph.model.layers.attention import DistanceAttnBias, build_distance_attn_bias + +__all__ = [ + "CondPorts", + "ConditioningDiTBackboneWithHeads", + "DistanceAttnBias", + "KeepEdgeCond", + "KeepNodeCond", + "LightningModuleRatesFinetune", + "LightweightContextEncoder", + "build_distance_attn_bias", + "load_pretrained_into_lightning_model", +] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7c7c9517fbb9578b1e7166c04e7c39e255cdb95 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/conditional_backbone.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/conditional_backbone.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ece0f1aa65bbc6ec5c152387e0152e2ddde5679a Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/conditional_backbone.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/context_encoder.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/context_encoder.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e7099795c27d2c501613b9feeddaf06000cc7e91 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/context_encoder.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/finetune_base.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/finetune_base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a6bf4cb63ba283832c468719b7e8524987f3b15c Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/__pycache__/finetune_base.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/conditional_backbone.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/conditional_backbone.py new file mode 100644 index 0000000000000000000000000000000000000000..f9c2e279574ac3c93195f335f041f855ed2a1f5e --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/conditional_backbone.py @@ -0,0 +1,78 @@ +"""Template-method base for task-specific conditioning backbones. + +The scaffold wrapper extends +:class:`~morph.model.backbones.conditional_dit.ConditionalDiTBackboneWithHeads` +with the *same* ``forward``: take the standard conditioning ports, optionally +populate some of them from task-specific inputs, then delegate to the parent. +Only the "populate" step differs between tasks, so it is the single overridable +hook (:meth:`_build_conditioning`); the (long) signature and the delegation live +here once instead of being copy-pasted into every subclass. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from morph.model.backbones.conditional_dit import ConditionalDiTBackboneWithHeads + +if TYPE_CHECKING: + import torch + + from morph.dataset.core.atomic_data import ChemBatch + +# The five conditioning ports threaded from ``forward`` into ``super().forward``. +CondPorts = dict[str, "torch.Tensor | None"] + + +class ConditioningDiTBackboneWithHeads(ConditionalDiTBackboneWithHeads): + """A :class:`ConditionalDiTBackboneWithHeads` with a task-specific + conditioning hook run in front of the conditional forward. + """ + + def _build_conditioning( + self, + mols_t: ChemBatch, + cond: CondPorts, + force_uncond: bool, + ) -> CondPorts: + """Populate / adjust the conditioning ports for this task. + + ``cond`` holds the five caller-supplied ports (``cond_tokens``, + ``cond_token_mask``, ``cond_token_attn_bias``, ``node_cond``, + ``edge_cond``). Caller-supplied (non-``None``) values + must be preserved — the inference-time caching hook passes them + directly. Returns the (possibly updated) dict. Default: no conditioning. + """ + return cond + + def forward( + self, + mols_t: ChemBatch, + t: torch.Tensor, + cond_tokens: torch.Tensor | None = None, + cond_token_mask: torch.Tensor | None = None, + cond_token_attn_bias: torch.Tensor | None = None, + node_cond: torch.Tensor | None = None, + edge_cond: torch.Tensor | None = None, + max_seqlen: int | None = None, + force_uncond: bool = False, + ) -> dict[str, Any]: + """Encode task context and run the conditional DiT with its heads.""" + force_uncond = force_uncond or bool(getattr(mols_t, "force_uncond", False)) + cond = self._build_conditioning( + mols_t, + { + "cond_tokens": cond_tokens, + "cond_token_mask": cond_token_mask, + "cond_token_attn_bias": cond_token_attn_bias, + "node_cond": node_cond, + "edge_cond": edge_cond, + }, + force_uncond, + ) + return super().forward( + mols_t, + t, + max_seqlen=max_seqlen, + **cond, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/context_encoder.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/context_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..57fd7288745fbc07c5753ed3a700676b57403b62 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/context_encoder.py @@ -0,0 +1,321 @@ +"""Lightweight context encoding for conditional fine-tuning. + +The scaffold recipe conditions a frozen DiT on the scaffold atoms by encoding +them into cross-attention tokens with a per-atom featuriser: + + type embedding + charge embedding + 3D-position MLP -> concat -> + token MLP (-> ctx_dim) -> pack to dense ``(B, K, ctx_dim)`` -> + optional self-attention so the tokens mix. + +:class:`LightweightContextEncoder` factors that shared featurization out into +``atom_embed`` / ``charge_embed`` / ``pos_proj`` / ``token_proj`` / ``attn``. + +This class directly encodes the scaffold subgraph for scaffold decoration. + +The optional ``use_pair_bias`` flavour biases the self-attention with an +edge-based :class:`~morph.model.layers.attention.PairBias` (RBF distance + +edge-type of the context molecule's own bonds), so tokens mix with +geometry/topology awareness instead of plain token-space attention. + +The per-node / per-edge scaffold-membership conditioning modules +(:class:`KeepNodeCond`, :class:`KeepEdgeCond`) also live here. + +The output projection is intentionally *un-gated*: the clean lane's +cross-attention gates carry the zero-init identity guarantee, so a freshly +initialised encoder leaves an unconditional checkpoint unchanged at step 0. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +import torch.nn as nn +from torch_geometric.utils import to_dense_batch + +from morph.model.layers.attention import BiasedSelfAttentionBlock, PairBias +from morph.model.layers.embedding import RBFEmbedding +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from morph.dataset.core.atomic_data import ChemBatch + + +class LightweightContextEncoder(nn.Module): + """Per-atom (type/charge/position) featuriser + optional self-attention. + + Carries no pretrained weights — all parameters train from scratch, so + :meth:`freeze_pretrained_body` is a no-op (present so the wrappers' + ``freeze_pretrained`` dispatch is uniform across encoder types). + + Args: + num_atom_types: Atom-type vocabulary size. + num_charges: Charge vocabulary size. + atom_embed_dim / charge_embed_dim / pos_embed_dim: Per-feature widths. + ctx_dim: Output token width. Must match ``ConditionalDiTBackbone.ctx_dim``. + extra_feat_dim: Extra per-atom feature width concatenated *before* the + token MLP (e.g. error embeddings). 0 for the plain encoders. + num_attn_layers: Transformer self-attention layers over the tokens of + each graph. 0 → mixing-free per-atom encoder (no ``attn`` submodule). + nhead / mlp_ratio / dropout: Self-attention hyper-parameters. + use_pair_bias: When True, the self-attention is biased by an + edge-based :class:`~morph.model.layers.attention.PairBias` (RBF of + pairwise distance + edge-type embedding of the context molecule's + own bonds), so the tokens mix with geometry/topology awareness like + the DiT backbone — instead of the plain token-space attention. The + biased path reads ``mol.e`` / ``mol.edge_index`` in :meth:`encode` + (the plain path needs only nodes). Requires ``num_attn_layers > 0`` + and a ``num_edge_types``. + num_edge_types / edge_embed_dim / num_rbf / rbf_dmax / + pair_bias_hidden_dim: pair-bias hyper-parameters (used only when + ``use_pair_bias``). + """ + + def __init__( + self, + num_atom_types: int, + num_charges: int, + atom_embed_dim: int = 64, + charge_embed_dim: int = 16, + pos_embed_dim: int = 32, + ctx_dim: int = 128, + extra_feat_dim: int = 0, + num_attn_layers: int = 0, + nhead: int = 8, + mlp_ratio: float = 4.0, + dropout: float = 0.0, + use_pair_bias: bool = False, + num_edge_types: int | None = None, + edge_embed_dim: int = 32, + num_rbf: int = 32, + rbf_dmax: float = 5.0, + pair_bias_hidden_dim: int = 32, + ): + super().__init__() + self.ctx_dim = ctx_dim + + self.atom_embed = nn.Embedding(num_atom_types, atom_embed_dim) + self.charge_embed = nn.Embedding(num_charges, charge_embed_dim) + self.pos_proj = nn.Sequential( + nn.Linear(3, pos_embed_dim), + nn.GELU(), + nn.Linear(pos_embed_dim, pos_embed_dim), + ) + + feat_dim = atom_embed_dim + charge_embed_dim + pos_embed_dim + extra_feat_dim + self.token_proj = nn.Sequential( + nn.Linear(feat_dim, ctx_dim), + nn.GELU(), + nn.Linear(ctx_dim, ctx_dim), + ) + + # Two self-attention flavours. ``use_pair_bias`` swaps the plain + # token-space TransformerEncoder for a custom biased stack that respects + # the context molecule's bonds/geometry. They are mutually exclusive; + # only the active one holds parameters. + self.attn: nn.TransformerEncoder | None = None + self.biased_blocks: nn.ModuleList | None = None + self.edge_embed: nn.Embedding | None = None + self.pair_bias: PairBias | None = None + + if use_pair_bias: + if num_attn_layers <= 0: + raise ValueError("use_pair_bias requires num_attn_layers > 0") + if num_edge_types is None: + raise ValueError("use_pair_bias requires num_edge_types") + self.edge_embed = nn.Embedding(num_edge_types, edge_embed_dim) + self.pair_bias = PairBias( + rbf_embedding=RBFEmbedding( + num_rbf=num_rbf, + rbf_dmax=rbf_dmax, + out_dim=num_rbf, + trainable=False, + ), + rbf_out_dim=num_rbf, + edge_nf=edge_embed_dim, + num_heads=nhead, + hidden_dim=pair_bias_hidden_dim, + ) + self.biased_blocks = nn.ModuleList( + BiasedSelfAttentionBlock(ctx_dim, nhead, mlp_ratio, dropout) + for _ in range(num_attn_layers) + ) + elif num_attn_layers > 0: + layer = nn.TransformerEncoderLayer( + d_model=ctx_dim, + nhead=nhead, + dim_feedforward=int(ctx_dim * mlp_ratio), + dropout=dropout, + activation="gelu", + batch_first=True, + norm_first=True, + ) + self.attn = nn.TransformerEncoder( + layer, num_layers=num_attn_layers, norm=nn.LayerNorm(ctx_dim) + ) + + # ------------------------------------------------------------------ # + # Shared building blocks used by subclass forwards. + # ------------------------------------------------------------------ # + def base_feats( + self, a: torch.Tensor, c: torch.Tensor, x: torch.Tensor + ) -> torch.Tensor: + """Concatenated per-atom ``[type | charge | position]`` features.""" + a_embed = self.atom_embed(a) + c_embed = self.charge_embed(c) + x_embed = self.pos_proj(x.to(dtype=a_embed.dtype)) + return torch.cat([a_embed, c_embed, x_embed], dim=-1) + + def apply_self_attn( + self, cond_tokens: torch.Tensor, cond_token_mask: torch.Tensor + ) -> torch.Tensor: + """Mix tokens within each graph; zero padded rows (no NaN leakage).""" + if self.attn is None: + return cond_tokens + out = self.attn(cond_tokens, src_key_padding_mask=~cond_token_mask) + return out.masked_fill(~cond_token_mask.unsqueeze(-1), 0.0) + + def encode( + self, mol: ChemBatch, extra_feat: torch.Tensor | None = None + ) -> tuple[torch.Tensor, torch.Tensor]: + """Full pipeline: featurise every atom → tokens → dense → self-attn. + + Args: + mol: a :class:`ChemBatch` exposing ``.a``, ``.c``, ``.x``, ``.batch`` + (and, when ``use_pair_bias``, ``.e`` / ``.edge_index``). + extra_feat: optional ``(N, extra_feat_dim)`` per-atom features to + concatenate before the token MLP. + + Returns ``(cond_tokens, cond_token_mask)`` of shapes + ``(B, K_max, ctx_dim)`` and ``(B, K_max)``. + """ + batch = mol.batch + batch_size = mol.num_graphs + feats = self.base_feats(mol.a, mol.c, mol.x) + if extra_feat is not None: + feats = torch.cat([feats, extra_feat.to(dtype=feats.dtype)], dim=-1) + tokens = self.token_proj(feats) + cond_tokens, cond_token_mask = to_dense_batch( + tokens, batch, batch_size=batch_size + ) + if self.pair_bias is not None: + cond_tokens = self._apply_biased_self_attn( + cond_tokens, cond_token_mask, mol, batch, batch_size + ) + else: + cond_tokens = self.apply_self_attn(cond_tokens, cond_token_mask) + return cond_tokens, cond_token_mask + + def _apply_biased_self_attn( + self, + cond_tokens: torch.Tensor, + cond_token_mask: torch.Tensor, + mol: ChemBatch, + batch: torch.Tensor, + batch_size: int, + ) -> torch.Tensor: + """Self-attention biased by the context molecule's own edges/geometry.""" + K = cond_tokens.shape[1] + edge_index = mol.edge_index + e_embed = self.edge_embed(mol.e) + triu_edge_positions, _ = batched_triu_edge_layout( + edge_index, batch, mol.num_nodes_per_graph + ) + # (B, H, K, K), already -inf at padded pairs. K matches the token pad + # length because both dense-pack ``batch`` at the same max node count. + bias, atom_mask, _ = self.pair_bias( + mol.x, + (edge_index[0], edge_index[1]), + e_embed, + batch, + batch_size, + K, + lengths=mol.num_nodes_per_graph, + triu_edge_positions=triu_edge_positions, + ) + # A fully padded query row is all -inf → NaN softmax. Zero those rows' + # bias (their outputs are masked to 0 below anyway). + bias = bias.masked_fill((~atom_mask)[:, None, :, None], 0.0) + for block in self.biased_blocks: + cond_tokens = block(cond_tokens, bias) + return cond_tokens.masked_fill(~cond_token_mask.unsqueeze(-1), 0.0) + + @torch.compiler.disable + def forward(self, mol: ChemBatch) -> tuple[torch.Tensor, torch.Tensor]: + """Encode every atom of ``mol`` into dense cross-attention tokens + ``(cond_tokens (B,K,ctx_dim), cond_token_mask (B,K))``. + + The scaffold wrapper instantiates this class directly. Compiler-disabled: + ``to_dense_batch`` / + `PairBias` derive shapes dynamo can't trace. Subclasses that need extra + per-atom inputs (error tokens) override ``forward`` with their own + signature; the plain per-atom / per-structure encoders use this as-is. + """ + return self.encode(mol) + + def freeze_pretrained_body(self) -> int: + """No-op: this encoder has no pretrained-loaded body. Returns 0.""" + return 0 + + +# Categorical edge types by scaffold membership of the two endpoints. +_EDGE_DEC_DEC = 0 # both endpoints in decoration +_EDGE_SC_DEC = 1 # exactly one endpoint in the scaffold +_EDGE_SC_SC = 2 # both endpoints in the scaffold +_N_EDGE_SCAFFOLD_TYPES = 3 + + +class KeepEdgeCond(nn.Module): + """Per-edge scaffold-class → pre-embedded ``edge_cond``. + + Classifies each edge as decoration-decoration / scaffold-decoration / + scaffold-scaffold and looks it up straight at ``in_edge_nf`` (embed-outside: + the backbone adds it through a zero-init ``edge_cond_gate``). Recomputed each + forward from the live ``edge_index`` + ``keep_mask`` so it tracks the + integrator's deletions/insertions. Returns ``None`` when the batch carries no + ``keep_mask``. + """ + + def __init__(self, in_edge_nf: int): + super().__init__() + self.embed = nn.Embedding(_N_EDGE_SCAFFOLD_TYPES, in_edge_nf) + + def forward(self, mols_t: ChemBatch) -> torch.Tensor | None: + """Embed retained-atom edge classes; return None without a keep mask.""" + sc_mask = getattr(mols_t, "keep_mask", None) + if sc_mask is None: + return None + sc_mask = sc_mask.bool() + edge_index = mols_t.edge_index + edge_types = ( + sc_mask[edge_index[0]].long() + sc_mask[edge_index[1]].long() + ).clamp(max=_EDGE_SC_SC) + out = self.embed(edge_types) + # Type 0 (editable-editable) carries NO signal — only kept-touching edges + # condition. An all-zero keep_mask therefore yields + # zero edge_cond instead of a constant leak through the shared gate. + return out * (edge_types > 0).unsqueeze(-1).to(out.dtype) + + +class KeepNodeCond(nn.Module): + """Per-node scaffold membership (0 = decoration, 1 = keep) → ``node_cond``. + + Recomputed each forward from the live ``keep_mask``. Returns ``None`` when + the batch carries no ``keep_mask``. + """ + + def __init__(self, d_model: int): + super().__init__() + self.embed = nn.Embedding(2, d_model) + + def forward(self, mols_t: ChemBatch) -> torch.Tensor | None: + """Embed kept atoms while leaving editable atoms unconditioned.""" + sc_mask = getattr(mols_t, "keep_mask", None) + if sc_mask is None: + return None + keep = sc_mask.long() + out = self.embed(keep) + # Only KEPT atoms (keep_mask=1) condition; editable atoms (0) carry no + # signal, so an all-zero keep_mask yields zero + # node_cond instead of a constant leak through the shared node_gate. + return out * keep.unsqueeze(-1).to(out.dtype) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/finetune_base.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/finetune_base.py new file mode 100644 index 0000000000000000000000000000000000000000..6d2edef01652c593fad32d9db6a8ca72ef3eef97 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/base/finetune_base.py @@ -0,0 +1,249 @@ +"""Shared fine-tuning LightningModule and pretrained-checkpoint loading. + +:class:`LightningModuleRatesFinetune` loads an unconditional DiT checkpoint into +the clean lane at init and (optionally) freezes it, dispatching to the model's own +``freeze_pretrained``. The module-level helpers do the checkpoint surgery. + +The unconditional DiT checkpoints store backbone weights at +``state_dict["model.backbone."]``. When fine-tuning with a +``ConditionalDiTBackboneWithHeads`` (which composes a ``DiTBackbone`` as +``self.backbone.dit``), the matching destination keys are ``backbone.dit.``; +:func:`load_pretrained_into_lightning_model` rewrites keys accordingly and uses +``load_state_dict(strict=False)`` so the cross-attn blocks (zero-gated at init) and +any other new modules keep their initial values. + +The task-specific subclass lives in the sibling ``scaffold`` package. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path + +import torch + +from morph.model.lightning.base import LightningModuleRates +from morph.utils.checkpoints import canonical_checkpoint_key + +LOGGER = logging.getLogger(__name__) + + +def _extract_lightning_state_dict( + ckpt: dict, prefer_ema: bool = False +) -> dict[str, torch.Tensor]: + """Pull the model state dict out of a raw Lightning checkpoint. + + The Lightning module wraps the actual model under ``model.<...>`` and an + EMA copy under ``model_ema.<...>``. We return just the keys under + ``model.`` (or ``model_ema.`` if ``prefer_ema``), with that prefix + stripped so they line up with ``self.model.load_state_dict`` callers. + """ + sd = ckpt.get("state_dict", ckpt) + src_prefix = "model_ema." if prefer_ema else "model." + fallback = "model." if prefer_ema else "model_ema." + + matches = {k: v for k, v in sd.items() if k.startswith(src_prefix)} + if not matches: + matches = {k: v for k, v in sd.items() if k.startswith(fallback)} + if not matches: + raise ValueError( + "No keys starting with 'model.' or 'model_ema.' in checkpoint." + ) + src_prefix = fallback + + # Strip the outer wrapper and any torch.compile() ``_orig_mod`` component. + stripped: dict[str, torch.Tensor] = {} + for k, v in matches.items(): + name = canonical_checkpoint_key(k[len(src_prefix) :]) + stripped[name] = v + LOGGER.info( + "Extracted %d params from checkpoint under '%s'.", len(stripped), src_prefix + ) + return stripped + + +def _retarget_backbone_keys_for_conditional( + state_dict: dict[str, torch.Tensor], +) -> dict[str, torch.Tensor]: + """Rewrite ``backbone.`` → ``backbone.dit.``. + + Used when loading a vanilla DiT checkpoint into a ConditionalDiT + backbone, which holds the DiT under ``self.backbone.dit``. + """ + pattern = re.compile(r"^backbone\.(?!dit\.)(.+)") + retargeted = {} + for key, value in state_dict.items(): + match = pattern.match(key) + if match: + retargeted[f"backbone.dit.{match.group(1)}"] = value + else: + retargeted[key] = value + return retargeted + + +def load_pretrained_into_lightning_model( + model: torch.nn.Module, + ckpt_path: str | Path, + *, + prefer_ema: bool = False, + strict: bool = False, +) -> tuple[list[str], list[str]]: + """Load a pretrained checkpoint into a Lightning model's ``self.model``. + + Automatically retargets vanilla-DiT keys to the ConditionalDiT layout when + necessary. Returns ``(missing, unexpected)`` from ``load_state_dict``. + + Args: + model: the inner model (``LightningModuleRates.model``), NOT the + outer Lightning module. + ckpt_path: Path to the Lightning checkpoint (``.ckpt`` file). + prefer_ema: If True, load from ``model_ema.*`` keys (typically what + you want for fine-tuning since EMA weights are smoother). + strict: Forwarded to ``load_state_dict``. Default ``False`` because + new modules (cross-attn blocks, conditioning gates/encoders) won't + exist in the source checkpoint. + """ + ckpt_path = Path(ckpt_path) + if not ckpt_path.exists(): + raise FileNotFoundError(f"Pretrained checkpoint not found: {ckpt_path}") + + LOGGER.info( + "Loading pretrained weights from %s (prefer_ema=%s)", ckpt_path, prefer_ema + ) + raw = torch.load(ckpt_path, map_location="cpu", weights_only=False) + sd = _extract_lightning_state_dict(raw, prefer_ema=prefer_ema) + + # Retarget ONLY a vanilla-DiT source (keys ``backbone.

``) into the + # ConditionalDiT layout (``backbone.dit.

``). An already-conditional source + # (its own ``backbone.dit.*`` + ``backbone.cross_attn_blocks.*`` + + # ``backbone.graph_gate``) must load as-is: retargeting would mis-move the + # cross-attn adapter and gates under ``backbone.dit.*``, where the destination + # has no such params, so they drop under ``strict=False`` and the trained + # adapter silently resets to init. Guard on the SOURCE, not just the dest. + dest_is_conditional = any( + name.startswith("backbone.dit.") for name, _ in model.named_parameters() + ) + source_is_conditional = any(k.startswith("backbone.dit.") for k in sd) + if dest_is_conditional and not source_is_conditional: + sd = _retarget_backbone_keys_for_conditional(sd) + + missing, unexpected = model.load_state_dict(sd, strict=strict) + if missing: + LOGGER.info( + "Pretrained load: %d missing keys (expected for new modules like " + "cross_attn_blocks). First few: %s", + len(missing), + missing[:5], + ) + if unexpected: + LOGGER.warning( + "Pretrained load: %d unexpected keys (may indicate arch mismatch). " + "First few: %s", + len(unexpected), + unexpected[:5], + ) + return missing, unexpected + + +class LightningModuleRatesFinetune(LightningModuleRates): + """Fine-tuning variant that loads pretrained DiT weights at init. + + The shared Lightning module delegates its Muon/AuxAdam behavior to the + optimizer mixin configured by ``configs/model/backbones/dit.yaml``. + ``freeze_output_heads`` defaults to ``freeze_pretrained_backbone``; an + explicit boolean controls head freezing independently of the shared features. + """ + + def __init__( + self, + *args, + pretrained_ckpt: str | None = None, + pretrained_prefer_ema: bool = True, + freeze_pretrained_backbone: bool = False, + freeze_output_heads: bool | None = None, + freeze_keep_positions: bool = False, + freeze_keep_charges: bool = False, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._freeze_keep_positions = freeze_keep_positions + self._freeze_keep_charges = freeze_keep_charges + + if pretrained_ckpt: + load_pretrained_into_lightning_model( + self.model, + pretrained_ckpt, + prefer_ema=pretrained_prefer_ema, + ) + # Re-sync the EMA copy so it starts from the loaded weights, + # not from the freshly instantiated (random) ones. + self.model_ema.load_state_dict(self.model.state_dict()) + + if freeze_pretrained_backbone: + # Each BackboneWithHeads subclass owns its freeze policy via + # ``freeze_pretrained()`` (no fragile name-prefix matching); a + # subclass may extend it to freeze its own encoder body too. + frozen = self.model.freeze_pretrained() + self.model_ema.freeze_pretrained() + # The EMA copy is never optimized, including any LoRA matrices + # that the model-side freeze policy deliberately re-enabled. + self.model_ema.requires_grad_(False) + LOGGER.info( + "Froze %s params via %s.freeze_pretrained().", + frozen, + type(self.model).__name__, + ) + + if freeze_output_heads is None: + freeze_output_heads = freeze_pretrained_backbone + changed = self.model.set_output_heads_trainable(not freeze_output_heads) + LOGGER.info( + "%s %s output-head params, including coordinate readout.", + "Froze" if freeze_output_heads else "Unfroze", + changed, + ) + + def _apply_inference_charge_mask(self, mol_t, c_pred): + """Keep input formal charges when the task treats them as constraints.""" + keep_mask = getattr(mol_t, "keep_mask", None) + if not self._freeze_keep_charges or keep_mask is None: + return c_pred + return torch.where(keep_mask.bool(), mol_t.c, c_pred) + + def _apply_inference_edit_masks( + self, + mol_t, + x1_pred, + do_sub_a_probs, + do_sub_e_probs, + do_del_probs, + num_ins_pred, + ): + """Force-fix the kept atoms & their internal edges at inference. + + Zeroes the substitution / deletion probabilities for the kept-atom set + (and sub-e for kept-kept edges) *before* the integrator, so kept-atom types + and bonds cannot drift during sampling — true inpainting. The keep set is + the ``keep_mask`` node-property port, which every edit task reuses as + its generic keep mask (Bemis-Murcko scaffold, grown fragment, linker + endpoints, …), not only literal scaffolds. ``num_ins_pred`` is left + untouched so decorations can still grow from kept-atom seeds. With + ``freeze_keep_positions``, the clean coordinate prediction is pinned to + the current kept coordinates, giving them zero Euler velocity. A no-op + when the batch carries no keep mask. + """ + keep_mask = getattr(mol_t, "keep_mask", None) # generic keep-set port + if keep_mask is None: + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred + keep_node = keep_mask.bool() + do_sub_a_probs = do_sub_a_probs.clone() + do_del_probs = do_del_probs.clone() + do_sub_a_probs[keep_node] = 0.0 + do_del_probs[keep_node] = 0.0 + keep_edge = keep_node[mol_t.edge_index[0]] & keep_node[mol_t.edge_index[1]] + do_sub_e_probs = do_sub_e_probs.clone() + do_sub_e_probs[keep_edge] = 0.0 + if self._freeze_keep_positions: + x1_pred = torch.where(keep_node[:, None], mol_t.positions, x1_pred) + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..972f2f322a1d356408e50a9a41239a45cf060343 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/__init__.py @@ -0,0 +1,15 @@ +"""Protein-pocket conditioning for a pretrained molecular DiT.""" + +from morph.model.finetune.pocket.pocket_context import ( + PocketConditionalDiTBackboneWithHeads, + PocketContextEncoder, +) +from morph.model.finetune.pocket.scaffold_pocket_context import ( + ScaffoldPocketConditionalDiTBackboneWithHeads, +) + +__all__ = [ + "PocketConditionalDiTBackboneWithHeads", + "PocketContextEncoder", + "ScaffoldPocketConditionalDiTBackboneWithHeads", +] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/pocket_context.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/pocket_context.py new file mode 100644 index 0000000000000000000000000000000000000000..85b33a5d4208d81d267f915cde2524ce36d922cf --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/pocket_context.py @@ -0,0 +1,87 @@ +"""Encode fixed protein atoms while leaving replay examples unconditional.""" + +from __future__ import annotations + +import hydra +import torch +from torch_geometric.utils import to_dense_batch + +from morph.model.finetune.base import ( + ConditioningDiTBackboneWithHeads, + DistanceAttnBias, + LightweightContextEncoder, + build_distance_attn_bias, +) + + +class PocketContextEncoder(LightweightContextEncoder): + """Protein atom encoder supporting empty context rows and entire empty batches.""" + + @torch.compiler.disable + def forward(self, pocket): + """Mask absent context without NaNs or unused parameters in replay batches.""" + tokens = self.token_proj(self.base_feats(pocket.a, pocket.c, pocket.x)) + dense, mask = to_dense_batch( + tokens, + pocket.batch, + batch_size=pocket.num_graphs, + max_num_nodes=max(1, pocket.max_seqlen), + ) + # Keep a zero-gradient dependency even when the input has no atoms. + dense = dense + tokens.sum() * 0.0 + safe_mask = mask.clone() + safe_mask[~mask.any(dim=1), 0] = True + dense = self.apply_self_attn(dense, safe_mask) + return dense.masked_fill(~mask.unsqueeze(-1), 0.0), mask + + +class PocketConditionalDiTBackboneWithHeads(ConditioningDiTBackboneWithHeads): + """Cross-attend to a pocket, with exact adapter bypass for empty context rows.""" + + required_rollout_context = ("pocket",) + + def __init__( + self, + *args, + encoder_args, + use_distance_bias=True, + dist_bias_init_gamma=1.0, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.encoder = hydra.utils.instantiate(encoder_args) + self.dist_bias = ( + DistanceAttnBias(dist_bias_init_gamma) if use_distance_bias else None + ) + + @torch.compiler.disable + def _encode_pocket(self, mols_t): + pocket = mols_t.pocket + tokens, mask = self.encoder(pocket) + bias = None + if self.dist_bias is not None: + bias = build_distance_attn_bias( + self.dist_bias, + mols_t.x, + mols_t.batch, + int(mols_t.max_seqlen), + pocket.x, + pocket.batch, + tokens.shape[1], + tokens.shape[0], + ) + return tokens, mask, bias + + def _build_conditioning(self, mols_t, cond, force_uncond): + if ( + not force_uncond + and getattr(mols_t, "pocket", None) is not None + and cond["cond_tokens"] is None + ): + tokens, mask, bias = self._encode_pocket(mols_t) + cond["cond_tokens"] = tokens + if cond["cond_token_mask"] is None: + cond["cond_token_mask"] = mask + if cond["cond_token_attn_bias"] is None: + cond["cond_token_attn_bias"] = bias + return cond diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/scaffold_pocket_context.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/scaffold_pocket_context.py new file mode 100644 index 0000000000000000000000000000000000000000..b1aa24ecea8c3a0f6492d354f1ea6ad778a42c0e --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/pocket/scaffold_pocket_context.py @@ -0,0 +1,33 @@ +"""Scaffold membership conditions nodes/edges; the protein supplies cross-attention.""" + +from morph.model.finetune.base import KeepEdgeCond, KeepNodeCond +from morph.model.finetune.pocket.pocket_context import ( + PocketConditionalDiTBackboneWithHeads, +) + + +class ScaffoldPocketConditionalDiTBackboneWithHeads( + PocketConditionalDiTBackboneWithHeads +): + """Compose scaffold constraints with protein context and empty replay rows. + + Coordinate pinning and topology/charge constraints use the shared scaffold + Lightning module's inference masks. All conditioning gates start at zero, + preserving the pretrained model's predictions before fine-tuning. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + bb = self.backbone + self.node_cond_encoder = KeepNodeCond(bb.d_model) if bb.use_node_cond else None + self.edge_cond_encoder = ( + KeepEdgeCond(bb.in_edge_nf) if bb.use_edge_cond else None + ) + + def _build_conditioning(self, mols_t, cond, force_uncond): + if getattr(mols_t, "keep_mask", None) is not None: + if cond["node_cond"] is None and self.node_cond_encoder is not None: + cond["node_cond"] = self.node_cond_encoder(mols_t) + if cond["edge_cond"] is None and self.edge_cond_encoder is not None: + cond["edge_cond"] = self.edge_cond_encoder(mols_t) + return super()._build_conditioning(mols_t, cond, force_uncond) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b0d8cde88fb6ab386fb9b6a83f6e359ca19ed3ce --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__init__.py @@ -0,0 +1,13 @@ +"""Scaffold-decoration model and Lightning module.""" + +from morph.model.finetune.scaffold.scaffold import ( + LightningModuleRatesScaffoldFinetune, +) +from morph.model.finetune.scaffold.scaffold_context import ( + ScaffoldConditionalDiTBackboneWithHeads, +) + +__all__ = [ + "LightningModuleRatesScaffoldFinetune", + "ScaffoldConditionalDiTBackboneWithHeads", +] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae346b2017a7c2d475453eb78f44f3b1f423b82a Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/scaffold.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/scaffold.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf82db7381f1d2924628f7ff7c4de3cfde1b327f Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/scaffold.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/scaffold_context.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/scaffold_context.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa8ef8503f3ebb6af0d1af68b1b78a1340fa3147 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/__pycache__/scaffold_context.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/scaffold.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/scaffold.py new file mode 100644 index 0000000000000000000000000000000000000000..fe9b7e62de2a9501701235196996b44fe7c1f237 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/scaffold.py @@ -0,0 +1,141 @@ +"""Scaffold-decoration fine-tuning LightningModule.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch.distributed as dist + +from morph.model.finetune.base import LightningModuleRatesFinetune +from morph.utils.metrics import calc_metrics_ +from morph.utils.metrics.scaffold import scaffold_row, summarize_scaffold_rows + +if TYPE_CHECKING: + import torch + + +class LightningModuleRatesScaffoldFinetune(LightningModuleRatesFinetune): + """Scaffold-aware fine-tuning. + + On top of pretrained loading from :class:`LightningModuleRatesFinetune`, + implements the two scaffold hooks defined on the base + :class:`LightningModuleRates`: + + * independent discrete and coordinate hooks return ``mols_t.keep_mask`` + when their corresponding exclusion flag is enabled. + The inference-time scaffold force-fix (``_apply_inference_edit_masks``) + lives on the shared parent :class:`LightningModuleRatesFinetune`; this class + adds the training-time loss exclusion. + + Retained atom identities and internal bonds are fixed. Scaffold positions + are supervised during training and may move at inference, matching the + original scaffold denoising protocol. + + ``freeze_keep_charges`` independently controls formal charges: fixed input + charges are excluded from the charge loss; predicted charges are supervised + on retained atoms even when atom substitutions remain excluded. + """ + + def __init__( + self, + *args, + exclude_scaffold_from_sub_loss: bool = True, + exclude_scaffold_from_x_loss: bool = False, + freeze_keep_positions: bool = False, + freeze_keep_charges: bool = True, + **kwargs, + ): + super().__init__( + *args, + freeze_keep_positions=freeze_keep_positions, + freeze_keep_charges=freeze_keep_charges, + **kwargs, + ) + self._exclude_scaffold_from_sub_loss = exclude_scaffold_from_sub_loss + self._exclude_scaffold_from_x_loss = exclude_scaffold_from_x_loss + + def _node_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + if not self._exclude_scaffold_from_sub_loss: + return None + return getattr(mols_t, "keep_mask", None) + + def _position_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + if not self._exclude_scaffold_from_x_loss: + return None + return getattr(mols_t, "keep_mask", None) + + def _charge_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + if not self._freeze_keep_charges: + return None + return getattr(mols_t, "keep_mask", None) + + def on_validation_epoch_start(self): + """Start a fresh population of scaffold-conditioned attempts.""" + super().on_validation_epoch_start() + self._scaffold_rows = [] + + def _validation_context(self, batch): + if not hasattr(batch[0], "scaffold_id"): + return None + # Sampling recenters its evolving state. Preserve the exact input frame + # before integration, moving each batch to CPU just once. + source, target = (graph.to("cpu") for graph in batch) + source.x *= self.distributions.coordinate_std + target.x *= self.distributions.coordinate_std + return source.to_data_list(), target.to_data_list() + + def _update_task_metrics(self, context, mols, rdkit_mols): + if context is None: + return + sources, targets = context + self._scaffold_rows.extend( + scaffold_row( + source, + target, + generated, + mol, + self.vocab, + allow_charged=self.allow_charged, + ) + for source, target, generated, mol in zip( + sources, targets, mols, rdkit_mols, strict=True + ) + ) + + def on_validation_epoch_end(self): + """Pool attempts across ranks before computing per-scaffold metrics.""" + rows = self._scaffold_rows + distributed = dist.is_available() and dist.is_initialized() + if distributed: + gathered = [None] * dist.get_world_size() if self.global_rank == 0 else None + dist.gather_object(rows, gathered, dst=0) + rows = [row for shard in gathered for row in shard] if gathered else [] + metrics = summarize_scaffold_rows(rows) if self.global_rank == 0 else {} + if distributed: + payload = [metrics] + dist.broadcast_object_list(payload, src=0) + metrics = payload[0] + if metrics: + self.log_dict( + {f"val/scaffold/{key}": value for key, value in metrics.items()}, + on_step=False, + on_epoch=True, + sync_dist=False, + ) + # Rows and RDKit molecules are gathered in the same rank/batch order. + # Retain the full rows until the shared chemistry evaluation consumes + # their input geometries. The scalar task summary deduplicates separately. + self._scaffold_rows = rows + super().on_validation_epoch_end() + self._scaffold_rows = [] + + def _compute_chemistry_metrics(self, rdkit_mols): + rows = self._scaffold_rows + contexts = [row.get("posebusters_reference") for row in rows] if rows else None + metrics = calc_metrics_(rdkit_mols, self.metrics, posebusters_contexts=contexts) + return { + key.replace("posebusters/scaffold_", "scaffold/posebusters/", 1) + if key.startswith("posebusters/scaffold_") + else key: value + for key, value in metrics.items() + } diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/scaffold_context.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/scaffold_context.py new file mode 100644 index 0000000000000000000000000000000000000000..8ffea81ddf0ed41fb3ccc5adc735b2ddeb1be146 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/finetune/scaffold/scaffold_context.py @@ -0,0 +1,145 @@ +"""Scaffold conditioning for scaffold-decoration fine-tuning. + +Two conditioning signals, **always on**, derived from ``mols_t.keep_mask`` +(both recomputed each forward so they track the integrator's del/ins): + +* ``node_cond`` — per-atom scaffold membership (0 = decoration, 1 = keep), via + the shared :class:`~morph.model.finetune.base.KeepNodeCond`. +* ``edge_cond`` — per-edge scaffold class (dec-dec / scaffold-dec / + scaffold-scaffold), via the shared + :class:`~morph.model.finetune.base.KeepEdgeCond`. + +Plus an **optional** cross-attention stream, toggled by the backbone's +``use_cross_attn`` flag: + +* **Option 1** (``use_cross_attn: false``) — node + edge conditioning only. +* **Option 2** (``use_cross_attn: true``) — node + edge + cross-attention. The + scaffold atoms are extracted from ``mols_t`` (induced subgraph via + :func:`~morph.dataset.core.atomic_data.filter_nodes`) and encoded into context + tokens by a :class:`~morph.model.finetune.base.LightweightContextEncoder`. + +Scaffold decoration carries no graph-level property condition. The per-node, +per-edge, and distance-bias building blocks live in +:mod:`morph.model.finetune.base.context_encoder`. +""" + +from __future__ import annotations + +import hydra +import torch + +from morph.dataset.core.atomic_data import ChemBatch, filter_nodes +from morph.model.finetune.base import ( + ConditioningDiTBackboneWithHeads, + CondPorts, + DistanceAttnBias, + KeepEdgeCond, + KeepNodeCond, + LightweightContextEncoder, + build_distance_attn_bias, +) + + +class ScaffoldConditionalDiTBackboneWithHeads(ConditioningDiTBackboneWithHeads): + """:class:`ConditionalDiTBackboneWithHeads` with built-in scaffold context. + + On every forward, builds ``node_cond`` + ``edge_cond`` from + ``mols_t.keep_mask`` (when the matching backbone port is enabled) and, + when ``use_cross_attn`` is set, encodes the scaffold subgraph into + ``cond_tokens`` via a shared ``LightweightContextEncoder``. Caller-supplied + kwargs always win (the inference-time caching hook: pass ``cond_tokens`` + directly to skip the encoder). + + Extra Hydra-instantiable args on top of the parent: + * ``xattn_encoder_args`` — the ``LightweightContextEncoder`` config; + required iff the backbone has ``use_cross_attn: true``. + * ``use_distance_bias`` / ``dist_bias_init_gamma`` — geometric + correspondence prior (query atom ↔ co-located scaffold token). + """ + + def __init__( + self, + *args, + xattn_encoder_args: dict | None = None, + use_distance_bias: bool = True, + dist_bias_init_gamma: float = 1.0, + **kwargs, + ): + super().__init__(*args, **kwargs) + + bb = self.backbone + self.node_cond_encoder = KeepNodeCond(bb.d_model) if bb.use_node_cond else None + self.edge_cond_encoder = ( + KeepEdgeCond(bb.in_edge_nf) if bb.use_edge_cond else None + ) + + # Optional cross-attention (option 2): encode the scaffold subgraph with + # a lightweight encoder shared by conditional fine-tuning tasks. + if bb.use_cross_attn: + if xattn_encoder_args is None: + raise ValueError( + "backbone.use_cross_attn is true but xattn_encoder_args is None; " + "provide the LightweightContextEncoder config, or set " + "use_cross_attn=false for node+edge-only scaffold conditioning." + ) + self.xattn_encoder: LightweightContextEncoder | None = ( + hydra.utils.instantiate(xattn_encoder_args) + ) + self.dist_bias = ( + DistanceAttnBias(dist_bias_init_gamma) if use_distance_bias else None + ) + else: + self.xattn_encoder = None + self.dist_bias = None + + def freeze_pretrained(self) -> int: + """Freeze the clean lane; the scaffold conditioning modules stay + trainable (they carry no pretrained body). + """ + frozen = super().freeze_pretrained() + if self.xattn_encoder is not None: + frozen += self.xattn_encoder.freeze_pretrained_body() + return frozen + + # filter_nodes + the encoder run data-dependent shape ops dynamo can't trace; + # isolate them so the rest of the model still compiles. + @torch.compiler.disable + def _encode_scaffold_cross_attn( + self, mols_t: ChemBatch + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]: + """Encode the scaffold subgraph of ``mols_t`` into cross-attn tokens.""" + scaffold = filter_nodes(mols_t, mols_t.keep_mask.bool()) + cond_tokens, cond_token_mask = self.xattn_encoder(scaffold) + cond_token_attn_bias = None + if self.dist_bias is not None: + cond_token_attn_bias = build_distance_attn_bias( + self.dist_bias, + mols_t.x, + mols_t.batch, + int(mols_t.max_seqlen), + scaffold.x, + scaffold.batch, + cond_tokens.shape[1], + cond_tokens.shape[0], + ) + return cond_tokens, cond_token_mask, cond_token_attn_bias + + def _build_conditioning( + self, mols_t: ChemBatch, cond: CondPorts, force_uncond: bool + ) -> CondPorts: + if getattr(mols_t, "keep_mask", None) is not None: + if cond["node_cond"] is None and self.node_cond_encoder is not None: + cond["node_cond"] = self.node_cond_encoder(mols_t) + if cond["edge_cond"] is None and self.edge_cond_encoder is not None: + cond["edge_cond"] = self.edge_cond_encoder(mols_t) + if ( + not force_uncond + and self.xattn_encoder is not None + and cond["cond_tokens"] is None + ): + cond["cond_tokens"], cond["cond_token_mask"], sc_bias = ( + self._encode_scaffold_cross_attn(mols_t) + ) + if cond["cond_token_attn_bias"] is None: + cond["cond_token_attn_bias"] = sc_bias + return cond diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0a05254bc5f23cf8659009d164755c1c23312b00 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__init__.py @@ -0,0 +1,5 @@ +"""Reusable nn building blocks. + +Lower-level :class:`torch.nn.Module` pieces composed by the DiT backbones: +attention, feature/time/count embeddings, prediction heads, and GMM helpers. +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab202995a08257053c93f9f75b20cc449f1b7e3c Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/attention.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/attention.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e55bd2aebeee81c3a8ab4700a533d85c85a30d0 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/attention.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/embedding.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/embedding.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d332a158c1e80c0059973bb770b16352245c6039 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/embedding.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/gmm.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/gmm.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b281491d1602ec1868b65456de8be2f0e715c5fa Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/gmm.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/heads.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/heads.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62c759a1ceee2e21c96052298a9150347a6185fa Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/heads.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/kernels.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/kernels.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e1e803cedd8e539ea95561c740b4a63847db85e Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/kernels.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/lora.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/lora.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af8589616175201f82f63dab856825f0b864a628 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/__pycache__/lora.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/attention.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..42ac94775b08c26ca3f7cd392a26ed0549d47524 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/attention.py @@ -0,0 +1,571 @@ +# Private compiler patches are imported only when compilation is requested. +# ruff: noqa: PLC0415 + +"""Attention for the DiT / context backbones — one home for the attention path. + +Centralises packed variable-length self-attention, its packed<->dense plumbing, +the dense context self-attention block, and the additive attention biases: + +* :class:`BiasedSelfAttentionBlock` — dense pre-norm self-attention + FFN. +* :class:`PairBias` — per-head self-attention bias (RBF distance + edge type + + optional atom-pair type), + used by the DiT backbone / context encoders. +* :class:`DistanceAttnBias` — fixed ``-softplus(gamma)·d²`` cross-attention bias (shared + across heads). +* :func:`build_distance_attn_bias` — dense-batch masking/plumbing for that bias. + +Backbone self-attention runs in packed ``(total_N, ...)`` layout. Unbiased blocks use +FlashAttention's true variable-length kernel when the optional dependency is +installed and explicitly enabled. Packed biased attention defaults to PyTorch SDPA; +``MORPH_ATTENTION_BACKEND=flex`` opts into Triton FlexAttention on CUDA. +The dense context block uses PyTorch SDPA directly. +""" + +from __future__ import annotations + +import os +from functools import cache + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import to_dense_batch + +from morph.model.layers.kernels import pack_normalized_qkv + +try: + from flash_attn import flash_attn_varlen_qkvpacked_func # type: ignore + + _HAS_FLASH_ATTN = True +except ImportError: # pragma: no cover + flash_attn_varlen_qkvpacked_func = None + _HAS_FLASH_ATTN = False + +USE_FLASH_ATTN = os.environ.get("MORPH_FLASH_ATTN", "0") == "1" +BIASED_ATTENTION_BACKEND = os.environ.get("MORPH_ATTENTION_BACKEND", "sdpa") +if BIASED_ATTENTION_BACKEND not in {"sdpa", "flex"}: + raise ValueError("MORPH_ATTENTION_BACKEND must be 'sdpa' or 'flex'") +_FLEX_ATTENTION = None +if BIASED_ATTENTION_BACKEND == "flex": + from torch.nn.attention.flex_attention import flex_attention + + _FLEX_ATTENTION = torch.compile(flex_attention, dynamic=True) + + +@cache +def fix_efficient_attention_bias_metadata(): + """Keep symbolic bias-gradient alignment correct across multiples of 16. + + PyTorch 2.9's efficient-attention backward meta kernel chooses a Python + branch for aligned lengths. A dynamic compiled graph can retain the wrong + logical size or stride when lengths cross 32, 48, etc. Specify the logical + shape separately from storage strides computed with ceiling division. The CUDA + kernel + and all numerical attention operations remain PyTorch's native ones. + """ + if not torch.__version__.startswith("2.9."): + return + from torch._meta_registrations import meta__scaled_dot_product_efficient_backward + + def backward_meta( + grad_out, + query, + key, + value, + attn_bias, + out, + logsumexp, + philox_seed, + philox_offset, + dropout_p, + grad_input_mask, + is_causal=False, + scale=None, + ): + grad_q, grad_k, grad_v, _ = meta__scaled_dot_product_efficient_backward( + grad_out, + query, + key, + value, + attn_bias, + out, + logsumexp, + philox_seed, + philox_offset, + dropout_p, + [*grad_input_mask[:3], False], + is_causal, + scale, + ) + grad_bias = None + if attn_bias is not None and grad_input_mask[3]: + length = attn_bias.shape[-1] + aligned_length = ((length + 15) // 16) * 16 + # A padded allocation followed by [:length] can itself specialize + # the logical size to the aligned extent when first traced at an + # aligned length. Specify logical sizes and storage strides directly. + padded_sizes = (*attn_bias.shape[:-1], aligned_length) + strides = [] + stride = 1 + for size in reversed(padded_sizes): + strides.append(stride) + stride *= torch.sym_max(size, 1) + grad_bias = torch.empty_strided( + attn_bias.shape, + tuple(reversed(strides)), + dtype=attn_bias.dtype, + device=attn_bias.device, + ) + return grad_q, grad_k, grad_v, grad_bias + + torch.library.register_fake( + torch.ops.aten._scaled_dot_product_efficient_attention_backward.default, + backward_meta, + allow_override=True, + ) + + +@cache +def _flash_attention_device_supported(device: torch.device) -> bool: + """Whether flash-attn supports the CUDA architecture behind ``device``.""" + if device.type != "cuda": + return False + # flash-attn v2 requires Ampere (compute capability 8.x) or newer. Merely + # importing the extension is insufficient on mixed-generation clusters. + return torch.cuda.get_device_capability(device)[0] >= 8 + + +# --------------------------------------------------------------------------- +# Packed <-> dense index helpers (shared with conditional_dit via re-export) +# --------------------------------------------------------------------------- +def compute_local_index( + batch_idx: torch.Tensor, + batch_size: int, + lengths: torch.Tensor | None = None, +) -> torch.Tensor: + """Intra-graph position of each packed node (``batch_idx`` graph-contiguous).""" + if lengths is None: + lengths = torch.bincount(batch_idx, minlength=batch_size) + offsets = torch.cumsum(lengths, 0) - lengths + return ( + torch.arange(batch_idx.shape[0], device=batch_idx.device) - offsets[batch_idx] + ) + + +def compute_dense_index( + batch_idx: torch.Tensor, + batch_size: int, + max_seqlen: int, + lengths: torch.Tensor | None = None, +) -> torch.Tensor: + """Flat-node -> dense-slot map ``b * max_seqlen + intra_graph_pos``.""" + return batch_idx * max_seqlen + compute_local_index(batch_idx, batch_size, lengths) + + +def pad_dense(x: torch.Tensor, dense_slot, batch_size, max_seqlen) -> torch.Tensor: + """Scatter packed values into ``(batch_size, max_seqlen, *rest)``.""" + flat = x.new_zeros((batch_size * max_seqlen, *x.shape[1:])) + flat[dense_slot] = x + return flat.view(batch_size, max_seqlen, *x.shape[1:]) + + +def unpad_dense(x_dense: torch.Tensor, dense_slot) -> torch.Tensor: + """Gather dense ``(B, max_seqlen, *rest)`` back to packed ``(total_N, *rest)``.""" + return x_dense.reshape(-1, *x_dense.shape[2:])[dense_slot] + + +class _UniqueGather(torch.autograd.Function): + @staticmethod + def forward(ctx, values, slots): + ctx.save_for_backward(slots) + ctx.input_shape = values.shape + return values[slots] + + @staticmethod + def backward(ctx, grad): + (slots,) = ctx.saved_tensors + result = grad.new_zeros(ctx.input_shape) + result[slots] = grad + return result, None + + +def unpad_dense_unique(values, slots): + """Gather unique dense slots with a direct-copy backward (profiling opt-in). + + Slots must come from compute_dense_index: each real atom owns one slot. + Repeated slots require the general unpad_dense implementation instead. + """ + flat = values.reshape(-1, *values.shape[2:]) + return _UniqueGather.apply(flat, slots) + + +# --------------------------------------------------------------------------- +# Packed self-attention +# --------------------------------------------------------------------------- +def flash_attention_supported(reference: torch.Tensor) -> bool: + """Whether the optional FlashAttention varlen kernel can run on ``reference``.""" + return ( + _HAS_FLASH_ATTN + and reference.is_cuda + and reference.dtype in (torch.float16, torch.bfloat16) + and _flash_attention_device_supported(reference.device) + ) + + +def packed_self_attention( + qkv: torch.Tensor, + cu_seqlens: torch.Tensor, + max_seqlen: int, + batch_idx: torch.Tensor, + *, + pair_bias: torch.Tensor | None = None, + atom_mask: torch.Tensor | None = None, + dense_slot: torch.Tensor | None = None, + q_norm: nn.Module | None = None, + k_norm: nn.Module | None = None, +) -> torch.Tensor: + """Variable-length self-attention on packed sequences. + + Args: + qkv: (total_N, 3, H, D) packed q/k/v. + cu_seqlens: (B+1,) int32 cumulative sequence lengths. + max_seqlen: largest sequence length in the batch (Python int). + batch_idx: (total_N,) batch assignment, used by the SDPA path. + pair_bias: optional dense ``(B, H, N, N)`` additive attention bias. + atom_mask: optional cached ``(B, N)`` valid-atom mask. + dense_slot: cached ``compute_dense_index`` scatter map (avoids repadding). + q_norm: Optional query normalization, fused with CUDA inference packing. + k_norm: Optional key normalization, fused with CUDA inference packing. + + Returns: + (total_N, H, D) + """ + use_flash = pair_bias is None and USE_FLASH_ATTN and flash_attention_supported(qkv) + qkv_padded = None + if dense_slot is not None and atom_mask is not None and not use_flash: + qkv_padded = pack_normalized_qkv(qkv, cu_seqlens, max_seqlen, q_norm, k_norm) + if qkv_padded is None and q_norm is not None and k_norm is not None: + q, k, v = qkv.unbind(dim=1) + qkv = torch.stack((q_norm(q).to(q.dtype), k_norm(k).to(k.dtype), v), dim=1) + + if use_flash: + return flash_attn_varlen_qkvpacked_func( + qkv, cu_seqlens, max_seqlen, dropout_p=0.0, causal=False + ) + + # Dense SDPA handles the biased block and is the portable fallback. + batch_size = cu_seqlens.numel() - 1 + if dense_slot is not None: + if qkv_padded is None: + qkv_padded = pad_dense(qkv, dense_slot, batch_size, max_seqlen) + if atom_mask is None: + flat_mask = torch.zeros( + batch_size * max_seqlen, dtype=torch.bool, device=qkv.device + ) + flat_mask[dense_slot] = True + atom_mask = flat_mask.view(batch_size, max_seqlen) + + def _unpad(out): + return unpad_dense(out, dense_slot) + else: + qkv_padded, inferred_mask = to_dense_batch( + qkv, batch_idx, max_num_nodes=max_seqlen + ) + if atom_mask is None: + atom_mask = inferred_mask + + def _unpad(out): + return out[inferred_mask] + + q, k, v = qkv_padded.unbind(dim=2) + q = q.transpose(1, 2) + k = k.transpose(1, 2) + v = v.transpose(1, 2) + assert atom_mask is not None + attn_mask = pair_bias if pair_bias is not None else atom_mask[:, None, None, :] + if pair_bias is not None and BIASED_ATTENTION_BACKEND == "flex" and q.is_cuda: + # Torch 2.9 accumulates score-mod buffer gradients in FP32. Keep this + # differentiable cast so PairBias receives a gradient of its own dtype. + bias = attn_mask.float() + + def score_mod(score, b, h, qi, ki): + return score + bias[b, h, qi, ki] + + out = _FLEX_ATTENTION(q, k, v, score_mod=score_mod) + else: + out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask) + out = out.transpose(1, 2) + return _unpad(out) + + +# --------------------------------------------------------------------------- +# Dense context self-attention +# --------------------------------------------------------------------------- +class BiasedSelfAttentionBlock(nn.Module): + """Pre-norm self-attention + FFN over context tokens with an additive + per-head pair bias ``(B, H, K, K)`` (``-inf`` at masked pairs). Mirrors the + DiT block's biased SDPA attention but without adaLN conditioning — the + context encoder has no time signal. + """ + + def __init__(self, dim: int, nhead: int, mlp_ratio: float, dropout: float): + super().__init__() + assert dim % nhead == 0, "ctx_dim must be divisible by nhead" + self.nhead = nhead + self.norm1 = nn.LayerNorm(dim) + self.qkv = nn.Linear(dim, 3 * dim) + self.proj = nn.Linear(dim, dim) + self.norm2 = nn.LayerNorm(dim) + hidden = int(dim * mlp_ratio) + self.ffn = nn.Sequential( + nn.Linear(dim, hidden), nn.GELU(), nn.Linear(hidden, dim) + ) + self.drop = nn.Dropout(dropout) + + def forward(self, x: torch.Tensor, attn_bias: torch.Tensor) -> torch.Tensor: + """Mix dense tokens using the supplied per-head attention bias.""" + B, K, D = x.shape + h = self.norm1(x) + qkv = self.qkv(h).reshape(B, K, 3, self.nhead, D // self.nhead) + q, k, v = qkv.permute(2, 0, 3, 1, 4).unbind(0) # each (B, H, K, d) + o = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_bias) + o = o.transpose(1, 2).reshape(B, K, D) + x = x + self.drop(self.proj(o)) + x = x + self.drop(self.ffn(self.norm2(x))) + return x + + +# --------------------------------------------------------------------------- +# Self-attention pair bias (distance + edge type + optional atom-pair type) +# --------------------------------------------------------------------------- +class PairBias(nn.Module): + """Per-head additive attention bias from distance, edge, and atom-pair type. + + Logically the bias is:: + + bias[b, h, i, j] = down_proj( GELU( + distance_proj(rbf(||x_i - x_j||)) + + edge_proj(edge_features[i, j]) + + edge_present[i, j] * atom_pair_embedding({atom_type_i, atom_type_j}) + ) )[h] + + where ``edge_features[i, j] = e_embed`` at edges in ``edge_index`` and + zero elsewhere. Distance and hidden features are computed only for real + atom pairs. The small final per-head bias is scattered into the one dense + tensor needed by the biased SDPA block. + + The optional atom-pair table uses an unordered triangular index, so its + contribution is exactly symmetric under swapping atoms ``i`` and ``j``. + It is zero-initialized so enabling it preserves the pre-existing PairBias + at initialization while still receiving gradients through ``down_proj``. + Blocks gate the full attention residual via adaLN-Zero. + Padded pairs are ``-inf``-masked. ``down_proj`` is left at its Xavier init + (inherited from the parent backbone's ``_basic_init``). + """ + + def __init__( + self, + rbf_embedding: nn.Module, + rbf_out_dim: int, + edge_nf: int, + num_heads: int, + hidden_dim: int, + num_atom_types: int | None = None, + ): + super().__init__() + if num_atom_types is not None and num_atom_types <= 0: + raise ValueError("num_atom_types must be positive when provided") + self.num_heads = num_heads + self.num_atom_types = num_atom_types + self.rbf_embedding = rbf_embedding + self.dist_to_hidden = nn.Linear(rbf_out_dim, hidden_dim, bias=True) + self.edge_to_hidden = nn.Linear(edge_nf, hidden_dim, bias=False) + self.atom_pair_embedding: nn.Embedding | None = None + if num_atom_types is not None: + num_unordered_pairs = num_atom_types * (num_atom_types + 1) // 2 + self.atom_pair_embedding = nn.Embedding(num_unordered_pairs, hidden_dim) + nn.init.zeros_(self.atom_pair_embedding.weight) + self.act = nn.GELU(approximate="tanh") + self.down_proj = nn.Linear(hidden_dim, num_heads, bias=False) + + def _project_hidden(self, hidden: torch.Tensor) -> torch.Tensor: + """Read out one shared geometry bias per attention head.""" + return self.down_proj(self.act(hidden)) + + def _atom_pair_indices( + self, atom_types_i: torch.Tensor, atom_types_j: torch.Tensor + ) -> torch.Tensor: + """Return unordered triangular indices for paired atom types.""" + assert self.num_atom_types is not None + lower = torch.minimum(atom_types_i, atom_types_j) + upper = torch.maximum(atom_types_i, atom_types_j) + return lower * self.num_atom_types - lower * (lower - 1) // 2 + upper - lower + + def _scatter_bias( + self, + edge_bias, + diagonal_bias, + batch, + local, + rows, + cols, + batch_size, + max_seqlen, + symmetric, + ): + bias = edge_bias.new_full( + (batch_size, self.num_heads, max_seqlen, max_seqlen), -torch.inf + ) + bias[batch, :, local, local] = diagonal_bias + bias[batch[rows], :, local[rows], local[cols]] = edge_bias + if symmetric: + bias[batch[cols], :, local[cols], local[rows]] = edge_bias + return bias + + def _edge_hidden( + self, + e_embed: torch.Tensor, + rows: torch.Tensor, + cols: torch.Tensor, + atom_types: torch.Tensor | None, + ) -> torch.Tensor: + """Project edge features with the optional unordered atom-pair term.""" + edge_hidden = self.edge_to_hidden(e_embed) + if self.atom_pair_embedding is not None: + if atom_types is None: + raise ValueError( + "atom_types is required when atom-pair PairBias is enabled" + ) + pair_indices = self._atom_pair_indices(atom_types[rows], atom_types[cols]) + edge_hidden = edge_hidden + self.atom_pair_embedding(pair_indices) + return edge_hidden + + def forward( + self, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + batch: torch.Tensor, + batch_size: int, + max_seqlen: int, + atom_types: torch.Tensor | None = None, + lengths: torch.Tensor | None = None, + dense_slot: torch.Tensor | None = None, + triu_edge_positions: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Returns: + bias: (B, num_heads, max_seqlen, max_seqlen) additive attention + bias, already ``-inf`` at padded pairs so softmax masks them. + atom_mask: (B, max_seqlen) True at valid atoms (cached for the + attention path so it doesn't repad). + edge_distance_embed: RBF embeddings aligned with ``edge_index`` or, + when ``triu_edge_positions`` is supplied, those representative + undirected edges, for reuse by the edge decoder. + """ + if lengths is None: + lengths = torch.bincount(batch, minlength=batch_size) + if dense_slot is None: + dense_slot = compute_dense_index(batch, batch_size, max_seqlen, lengths) + local = dense_slot - batch * max_seqlen + + # Runtime molecular graphs are complete and directed without self-edges, + # so edge_index already enumerates every off-diagonal atom pair. Use it + # directly instead of rebuilding sum(n_i**2) pair indices and mapping the + # edges back into that layout. Only the shared zero-distance diagonal is + # missing. + full_rows, full_cols = edge_index + if triu_edge_positions is None: + rows, cols = full_rows, full_cols + selected_e_embed = e_embed + else: + rows = full_rows[triu_edge_positions] + cols = full_cols[triu_edge_positions] + selected_e_embed = e_embed[triu_edge_positions] + edge_distances = (x[rows] - x[cols]).norm(dim=-1) + distance_embed = self.rbf_embedding(torch.cat((edge_distances, x.new_zeros(1)))) + edge_distance_embed = distance_embed[:-1] + diagonal_distance_embed = distance_embed[-1:] + + edge_hidden = self.dist_to_hidden(edge_distance_embed) + edge_hidden = edge_hidden + self._edge_hidden( + selected_e_embed, rows, cols, atom_types + ).to(dtype=edge_hidden.dtype) + edge_bias = self._project_hidden(edge_hidden) + diagonal_bias = self._project_hidden( + self.dist_to_hidden(diagonal_distance_embed) + ) + + bias = self._scatter_bias( + edge_bias, + diagonal_bias, + batch, + local, + rows, + cols, + batch_size, + max_seqlen, + triu_edge_positions is not None, + ) + atom_mask = torch.arange(max_seqlen, device=x.device).unsqueeze( + 0 + ) < lengths.unsqueeze(1) + return ( + bias, + atom_mask, + edge_distance_embed, + ) + + +# --------------------------------------------------------------------------- +# Cross-attention distance biases +# --------------------------------------------------------------------------- +class DistanceAttnBias(nn.Module): + """Additive cross-attention bias from query↔key 3D distances. + + Gives the gated cross-attention a geometric **correspondence prior**: a query + atom attends most strongly to the context atom that sits at the *same + location*, falling off with distance. Returns a ``(B, K, N)`` bias matching + ``cond_token_attn_bias`` (``bias[b, k, n]`` is added to the logit of query + atom ``n`` attending to context token ``k``). Monotone in distance (max at + d=0), i.e. "attend to the co-located atom". + + Well-posed only when queries and keys share a 3D frame. The overall + cross-attn contribution is still governed by the per-block ``attn_gate`` + (~0 at init), so this only shapes WHERE attention lands. + + The single ``(B, K, N)`` map is broadcast over every head, so all heads share + the same learned length scale. + """ + + def __init__(self, init_gamma: float = 1.0): + super().__init__() + # softplus(raw_gamma) is the inverse length-scale² of the falloff. + g = max(float(init_gamma), 1e-4) + raw = torch.log(torch.expm1(torch.tensor(g))) if g < 20 else torch.tensor(g) + self.raw_gamma = nn.Parameter(raw) + + def forward(self, x_q: torch.Tensor, x_k: torch.Tensor) -> torch.Tensor: + """Return negative squared distances scaled by a positive coefficient.""" + # x_q: (B, N, 3) query positions; x_k: (B, K, 3) key positions. + d2 = torch.cdist(x_k.float(), x_q.float()).pow(2) # (B, K, N) + return -F.softplus(self.raw_gamma) * d2 + + +def build_distance_attn_bias( + dist_bias: DistanceAttnBias, + x_q: torch.Tensor, + batch_q: torch.Tensor, + n_pad_q: int, + x_k: torch.Tensor, + batch_k: torch.Tensor, + k_pad: int, + batch_size: int, +) -> torch.Tensor: + """Dense ``(B, k_pad, n_pad_q)`` geometric cross-attn bias (query atom ↔ + co-located context token). Shared plumbing for conditional + wrappers, which differ only in which batch supplies the key tokens. + """ + xq, _ = to_dense_batch(x_q, batch_q, batch_size=batch_size, max_num_nodes=n_pad_q) + xk, _ = to_dense_batch(x_k, batch_k, batch_size=batch_size, max_num_nodes=k_pad) + return dist_bias(xq, xk) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/embedding.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/embedding.py new file mode 100644 index 0000000000000000000000000000000000000000..10bc72dabda728e6e36aa916b70a7b886442973d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/embedding.py @@ -0,0 +1,394 @@ +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F + + +class Embedding(nn.Module): + """ + Advanced embedding layer for node features. + + Structure: + Input Indices -> Lookup -> Linear -> Norm -> Activation -> Dropout -> Output + """ + + def __init__( + self, + num_embeddings: int, + embedding_dim: int, + out_dim: int = None, + dropout: float = 0.0, + ): + """ + Args: + num_embeddings: Vocabulary size (e.g., number of unique node types). + embedding_dim: Size of the initial lookup vector. + out_dim: Size of the output vector (if None, typically matches + embedding_dim). + dropout: Probability of dropout. + """ + super().__init__() + + # If out_dim isn't specified, keep dimensions constant + if out_dim is None: + out_dim = embedding_dim + + # 1. The Base Lookup + self.emb = nn.Embedding(num_embeddings, embedding_dim) + + # 2. The Feature Projection Block (MLP) + self.projection = nn.Sequential( + nn.Linear(embedding_dim, out_dim), + nn.LayerNorm(out_dim), + nn.GELU(), + nn.Dropout(dropout), + ) + + # Optional: Initialize weights specifically for embeddings if needed + self._init_weights() + + def _init_weights(self): + # Xavier initialization often works better than default for projections + nn.init.xavier_uniform_(self.emb.weight) + for module in self.projection.modules(): + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + + def forward(self, x): + """Project categorical tokens with independent dropout per occurrence.""" + # Molecular vocabularies are tiny and contain many repeated tokens. + # Project each category once. Training's dense lookup avoids Inductor's + # expensive repeated-index embedding backward in deterministic mode. + # Keep independent dropout draws per occurrence during training, and + # avoid a large one-hot allocation for non-molecular vocabularies. + stochastic = self.training and any( + isinstance(layer, nn.Dropout) and layer.p > 0 for layer in self.projection + ) + if ( + stochastic + or self.emb.num_embeddings > 64 + or x.dtype not in (torch.int32, torch.int64) + ): + return self.projection(self.emb(x)) + table = self.projection(self.emb.weight) + if not torch.is_grad_enabled(): + # Match the dense lookup's autocast output (normalization may have + # promoted the projected table back to FP32). + if ( + torch.is_autocast_enabled(table.device.type) + and table.dtype != torch.float64 + ): + table = table.to(torch.get_autocast_dtype(table.device.type)) + return F.embedding(x, table) + selector = F.one_hot(x.long(), num_classes=self.emb.num_embeddings) + return selector.to(table.dtype) @ table + + +class SinusoidalEncoding(nn.Module): + """ + The mathematical engine for sinusoidal embeddings. + Computes: [sin(x*w0), cos(x*w0), sin(x*w1), cos(x*w1)...] + """ + + def __init__(self, embedding_dim: int, max_period: float = 10000.0): + super().__init__() + if embedding_dim % 2 != 0: + raise ValueError(f"Embedding dimension {embedding_dim} must be even.") + + self.embedding_dim = embedding_dim + half_dim = embedding_dim // 2 + + # Calculate frequencies (DDPM style / Vaswani style) + # We want frequencies ranging from 1 to 1/max_period + # The formula creates a geometric progression of frequencies + lambda_max = max_period + + # Derived from: exp(-2 * i / d * ln(10000)) + # This matches the JAX 'half_dim - 1' logic for exact boundary alignment + exponent = -math.log(lambda_max) * torch.arange( + start=0, end=half_dim, dtype=torch.float32 + ) + exponent = exponent / (half_dim - 1) + + freqs = torch.exp(exponent) + + # Register as fixed buffer (not a learnable parameter) + self.register_buffer("freqs", freqs) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Args: + x: Tensor of shape [Batch, 1] (values can be float or int) + + Returns: + Tensor of shape [Batch, embedding_dim] + """ + # x: [Batch, 1], freqs: [Half_Dim] + # args: [Batch, Half_Dim] + args = x.float() * self.freqs.unsqueeze(0) + + # Concatenate sin and cos -> [Batch, Dim] + embedding = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) + + return embedding + + +class ExtrapolatableCountEmbedding(nn.Module): + """Count embedding designed to extrapolate slightly outside the training range. + + A naive sinusoidal count embedding whose shortest period is ``2π`` (a + geometric series of frequencies starting at ``freq=1``) produces aliased, + non-monotonic features for small integer counts (e.g. QM9 with counts in + ``[3, 29]``): neighbouring counts map to unrelated sin/cos patterns, and + counts just outside the training support land on patterns the downstream + linear projection has never seen — so it cannot extrapolate. + + This module fixes both issues: + + * It uses a **low-frequency** Fourier basis whose shortest period is larger + than the expected training support (``min_period``), so every dimension + varies smoothly over the training range and its immediate neighbourhood. + * It concatenates a **raw normalised scalar** ``count / max_count``, giving + the MLP a strictly linear (monotonic, unbounded) axis to extrapolate + along — the Fourier basis only contributes local precision within range. + + With defaults tuned for QM9 (``min_period=64``, ``max_period=512``, + ``max_count=32``), counts in ``[0, 32]`` map to half a period at most on + any Fourier component, and ``count=30`` — just outside the training + support — produces an embedding that is a smooth continuation of the + ``count=29`` embedding rather than a novel aliased fingerprint. + + Input: Long / Float Tensor ``[Batch]`` or ``[Batch, 1]``. + Output: Float Tensor ``[Batch, out_dim]``. + """ + + def __init__( + self, + embedding_dim: int, + out_dim: int = None, + min_period: float = 64.0, + max_period: float = 512.0, + max_count: float = 32.0, + ): + super().__init__() + if out_dim is None: + out_dim = embedding_dim + if embedding_dim % 2 != 0: + raise ValueError( + f"embedding_dim {embedding_dim} must be even (sin/cos pairs)." + ) + if min_period <= 0 or max_period < min_period: + raise ValueError( + f"Expected 0 < min_period <= max_period, got " + f"min_period={min_period}, max_period={max_period}." + ) + if max_count <= 0: + raise ValueError(f"max_count must be > 0, got {max_count}.") + + self.embedding_dim = embedding_dim + self.out_dim = out_dim + self.min_period = float(min_period) + self.max_period = float(max_period) + self.max_count = float(max_count) + + # Log-spaced periods in [min_period, max_period]. Highest frequency + # has the *shortest* period (= min_period), ensuring every component + # stays in a monotonic half-cycle across [0, max_count]. + half_dim = embedding_dim // 2 + if half_dim == 1: + periods = torch.tensor([min_period], dtype=torch.float32) + else: + log_pmin = math.log(min_period) + log_pmax = math.log(max_period) + periods = torch.exp(torch.linspace(log_pmin, log_pmax, half_dim)) + self.register_buffer("freqs", 2 * math.pi / periods) + + # MLP takes [fourier_features, raw_scalar] and mixes them. The raw + # scalar is the path responsible for linear extrapolation. + self.projection = nn.Sequential( + nn.Linear(embedding_dim + 1, out_dim), + nn.SiLU(), + nn.Linear(out_dim, out_dim), + ) + + self._init_weights() + + def _init_weights(self): + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + + def forward(self, counts: torch.Tensor) -> torch.Tensor: + """Embed counts using Fourier features and a scaled scalar count.""" + if counts.ndim == 1: + counts = counts.unsqueeze(-1) + + x = counts.float() + args = x * self.freqs.unsqueeze(0) + fourier = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) + scalar = x / self.max_count + return self.projection(torch.cat([fourier, scalar], dim=-1)) + + +class TimeEmbedding(nn.Module): + """ + Embeds continuous time/noise levels. + Input: Float Tensor [Batch, 1] (e.g., values 0.0 to 1.0) + Output: Float Tensor [Batch, Dim] + """ + + def __init__(self, embedding_dim: int, out_dim: int = None): + super().__init__() + if out_dim is None: + out_dim = embedding_dim + # For time in [0,1], max_period=10000 ensures high precision + # for even very small time steps. + self.encoder = SinusoidalEncoding(embedding_dim, max_period=5000.0) + + # Standard MLP block for Diffusion/Flow models + # Projects the fixed sinusoids into the learnable semantic space + self.mlp = nn.Sequential( + nn.Linear(embedding_dim, embedding_dim), + nn.SiLU(), # SiLU is standard for time + nn.Linear(embedding_dim, out_dim), + ) + + def forward(self, times: torch.Tensor) -> torch.Tensor: + """Encode graph times after scaling the unit interval by 1000.""" + # Ensure input is [Batch, 1] + if times.ndim == 1: + times = times.unsqueeze(-1) + + times = times * 1000.0 + t_enc = self.encoder(times) + return self.mlp(t_enc) + + +class RBFEncoding(nn.Module): + """ + Trainable Radial Basis Function (RBF) encoding. + Both the centers and the widths (sigmas) of the Gaussians are learnable. + """ + + def __init__( + self, num_rbf: int = 16, rbf_dmax: float = 10.0, trainable: bool = True + ): + super().__init__() + self.num_rbf = num_rbf + self.rbf_dmax = rbf_dmax + + # 1. Initialize centers evenly spaced (same as before) + initial_centers = torch.linspace(0, rbf_dmax, num_rbf) + + # 2. Initialize sigmas (widths) + # The gap between centers is dmax / num_rbf. + # A good default sigma is often the gap size itself or slightly smaller. + initial_sigma = (rbf_dmax / num_rbf) * torch.ones(num_rbf) + + if trainable: + # Wrap in nn.Parameter to enable gradient descent + self.rbf_centers = nn.Parameter(initial_centers) + self.rbf_sigma = nn.Parameter(initial_sigma) + else: + # Keep them fixed if desired + self.register_buffer("rbf_centers", initial_centers) + self.register_buffer("rbf_sigma", initial_sigma) + + def forward(self, distances: torch.Tensor) -> torch.Tensor: + """Returns: [N, num_rbf]""" + if distances.ndim > 1: + distances = distances.squeeze(-1) + + # Broadcasting logic: + # distances: [N, 1] + # centers/sigmas: [num_rbf] + + # We calculate gamma dynamically based on the current learnable sigma + # Gamma = 1 / sigma^2 + # We use abs() or clamp() on sigma to ensure widths stay positive and non-zero + sigmas = torch.abs(self.rbf_sigma) + 1e-5 # Stability trick + gamma = 1.0 / (sigmas**2) + + # Formula: exp( -gamma * (x - mu)^2 ) + return torch.exp(-gamma * (distances.unsqueeze(-1) - self.rbf_centers) ** 2) + + +class RBFEmbedding(nn.Module): + """ + Radial Basis Function (RBF) embedding for distances with projection layer. + Wraps RBFEncoding with a learnable projection MLP. + + Structure: + Input Distances -> RBFEncoding -> Linear -> Norm -> Activation -> Dropout -> Output + + Input: Float Tensor [N] (distances) + Output: Float Tensor [N, out_dim] + """ + + def __init__( + self, + num_rbf: int = 16, + rbf_dmax: float = 10.0, + out_dim: int = None, + dropout: float = 0.0, + trainable: bool = True, + ): + """ + Args: + num_rbf: Number of RBF basis functions + rbf_dmax: Maximum distance for RBF + out_dim: Output dimension (if None, uses num_rbf) + dropout: Dropout probability + trainable: Whether RBF centers and widths receive gradients. + """ + super().__init__() + + # If out_dim isn't specified, keep dimensions constant + if out_dim is None: + out_dim = num_rbf + + # Store out_dim for easy access + self.out_dim = out_dim + + # RBF encoding layer + self.rbf_encoding = RBFEncoding( + num_rbf=num_rbf, rbf_dmax=rbf_dmax, trainable=trainable + ) + + # Simple projection block (matching Embedding class style) + self.projection = nn.Sequential( + nn.Linear(num_rbf, out_dim), + nn.LayerNorm(out_dim), + nn.GELU(), + nn.Dropout(dropout), + ) + + # Initialize weights + self._init_weights() + + def _init_weights(self): + """Xavier initialization for projection layers.""" + for module in self.projection.modules(): + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.zeros_(module.bias) + + def forward(self, distances: torch.Tensor) -> torch.Tensor: + """ + Compute RBF embeddings for distances. + + Args: + distances: Tensor of shape [N] or [N, 1] containing distances + + Returns: + Tensor of shape [N, out_dim] containing RBF embeddings + """ + # Get RBF encoding + rbf_enc = self.rbf_encoding(distances) # [N, num_rbf] + + # Apply projection + return self.projection(rbf_enc) # [N, out_dim] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/gmm.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/gmm.py new file mode 100644 index 0000000000000000000000000000000000000000..d64bcd8ca9f257052d2d5bde9543efe2459c0305 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/gmm.py @@ -0,0 +1,173 @@ +""" +GMM utilities for birth-death flow matching. + +This module contains utilities for working with Gaussian Mixture Models (GMMs) +in the context of birth-death flow matching, including parameter extraction, +GMM creation, sampling, and loss computation. +""" + +import torch +from torch.distributions import Categorical, Independent, Normal + + +def get_typed_gmm_components(gmm_output): + """ + Build component distributions from the typed insertion head's outputs. + + Args: + gmm_output (dict): Parameters for B spawning nodes and K components: + - 'mu': [B, K, D] means in normalized coordinates + - 'sigma': [B, K] positive isotropic scales + - 'pi': [B, K] normalized mixture weights + - 'a_probs': [B, K, N_atom_types] atom-type probabilities + - 'c_probs': [B, K, N_charge_types] charge probabilities + + Returns: + tuple: + - mixture_weights (Categorical): Batch shape [B, 1]. Event shape []. + - x_dist (Independent Normal): Batch shape [B, 1, K]. Event shape [D]. + - a_dist (Categorical): atom-type dist. Batch shape [B, 1, K]. Event shape + []. + - c_dist (Categorical): charge-type dist. Batch shape [B, 1, K]. Event shape + []. + """ + + # 1. Unpack and handle single-batch case (if necessary) + mu = gmm_output["mu"] # [B, K, D] + sigma = gmm_output["sigma"] # [B, K] + pi = gmm_output["pi"] # [B, K] + a_probs = gmm_output["a_probs"] # [B, K, T] + c_probs = gmm_output["c_probs"] # [B, K, T] + + # If the input was unbatched (K, D), add batch dim to make it (1, K, D) + if mu.dim() == 2: + mu = mu.unsqueeze(0) + sigma = sigma.unsqueeze(0) + pi = pi.unsqueeze(0) + a_probs = a_probs.unsqueeze(0) + c_probs = c_probs.unsqueeze(0) + + # 2. Reshape for Broadcasting: (B, ...) -> (B, 1, ...) + # This prepares the distributions to be evaluated against N_samples + # (broadcasting B, 1, K against B, N, K) + + # --- Mixture Weights --- + # Shape: [B, K] -> [B, 1, K] + # The insertion head already applies softmax to the mixture weights. + # Cast to fp32 + renormalize so bf16 softmax rounding doesn't trip the + # simplex tolerance enforced by ``Categorical(validate_args=True)``. + pi32 = pi.float() + pi32 = pi32 / pi32.sum(dim=-1, keepdim=True).clamp_min(1e-12) + mixture_weights = Categorical(probs=pi32.unsqueeze(1)) + + # --- Spatial Distribution --- + # Means: [B, K, D] -> [B, 1, K, D] + mu_expanded = mu.unsqueeze(1) + + # Sigmas: [B, K] -> [B, 1, K, 1] + # Note: We append a trailing 1 so it broadcasts against the D dimension of Mu. + # Since sigma is isotropic, it applies to x, y, and z equally. + sigma_expanded = sigma.unsqueeze(1).unsqueeze(-1) + + # Create Normal [B, 1, K, D] -> Wrap in Independent to sum log-probs over D + x_dist = Independent(Normal(mu_expanded, sigma_expanded), 1) + + # --- Type Distribution --- + # Shape: [B, K, T] -> [B, 1, K, T] + # Same fp32 + renormalize trick as the mixture weights above. + a32 = a_probs.float() + a32 = a32 / a32.sum(dim=-1, keepdim=True).clamp_min(1e-12) + c32 = c_probs.float() + c32 = c32 / c32.sum(dim=-1, keepdim=True).clamp_min(1e-12) + a_dist = Categorical(probs=a32.unsqueeze(1)) + c_dist = Categorical(probs=c32.unsqueeze(1)) + + return mixture_weights, x_dist, a_dist, c_dist + + +def sample_from_typed_gmm( + gmm_params, num_samples, K=10, D=1, N_a=4, N_c=4, squeeze_output=False +): + """ + Sample from the joint GMM created from predicted parameters. + + This function manually implements the 3-step sampling process: + 1. Sample component indices m ~ p(m) + 2. Gather parameters for p(x|m) and p(c|m) + 3. Sample types c ~ p(c|m) and locations x ~ p(x|m) + + Args: + gmm_params (dict): Parameters accepted by ``get_typed_gmm_components``. + num_samples (int): Number of samples to draw. + K (int): Compatibility argument; component count comes from the tensors. + D (int): Dimension of the data. + N_a (int): Number of discrete atom types. + N_c (int): Number of discrete charge types. + squeeze_output (bool): Remove a singleton leading batch dimension. + + Returns: + tuple: + - Locations: [B, num_samples, D]. + - Atom types: [B, num_samples]. + - Charges: [B, num_samples]. + The leading B dimension is removed when requested and B equals one. + """ + # --- 1. Get GMM Distributions --- + # mixture_weights: batch_shape=[B, 1] + # spatial_dist: batch_shape=[B, 1, K] + # type_dist: batch_shape=[B, 1, K] + mixture_weights, x_dist, a_dist, c_dist = get_typed_gmm_components(gmm_params) + + # --- 2. Sample Component Indices --- + # mixture_weights.sample() prepends sample_shape to batch_shape + # Shape: (num_samples, B, 1) + component_indices = mixture_weights.sample((num_samples,)) + + # Reshape to (B, num_samples) for gathering + component_indices = component_indices.permute(1, 0, 2).squeeze(-1) + + # --- 3. Gather Parameters for Chosen Components --- + + # Get all component parameters, squeezing out the '1' dim + # (B, 1, K, D) -> (B, K, D) + means_all = x_dist.base_dist.loc.squeeze(1) + scales_all = x_dist.base_dist.scale.squeeze(1) + # (B, 1, K, N_types) -> (B, K, N_types) + logits_all_a = a_dist.logits.squeeze(1) + logits_all_c = c_dist.logits.squeeze(1) + + # Create expanded indices for gathering + # (B, num_samples) -> (B, num_samples, 1) + idx = component_indices.unsqueeze(-1) + + # (B, num_samples, 1) -> (B, num_samples, D) + idx_locs = idx.expand(-1, -1, D) + # (B, num_samples, 1) -> (B, num_samples, N_types) + idx_a = idx.expand(-1, -1, N_a) + idx_c = idx.expand(-1, -1, N_c) + + # Gather the parameters for the sampled components + # We gather from dim 1 (the K dim) + # torch.gather(input, dim, index) + chosen_means = torch.gather(means_all, 1, idx_locs) + chosen_scales = torch.gather(scales_all, 1, idx_locs) + chosen_logits_a = torch.gather(logits_all_a, 1, idx_a) + chosen_logits_c = torch.gather(logits_all_c, 1, idx_c) + + # --- 4. Sample from Chosen Components --- + # Sample locations: x ~ p(x|m*) + # Shape: (B, num_samples, D) + sampled_x = Normal(chosen_means, chosen_scales).sample() + + # Sample types: c ~ p(c|m*) + # Shape: (B, num_samples) + sampled_a = Categorical(logits=chosen_logits_a).sample() + sampled_c = Categorical(logits=chosen_logits_c).sample() + + # --- 5. Handle non-batched output --- + if squeeze_output: + sampled_x = sampled_x.squeeze(0) + sampled_a = sampled_a.squeeze(0) + sampled_c = sampled_c.squeeze(0) + + return sampled_x, sampled_a, sampled_c diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/heads.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/heads.py new file mode 100644 index 0000000000000000000000000000000000000000..45ff2ae7907244eaa16c644305ce79c9d743a799 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/heads.py @@ -0,0 +1,780 @@ +"""Prediction heads for the molecular DiT. + +Shared SwiGLU adapters feed compact task projections. Inserted--inserted bonds +use an undirected decoder that observes both endpoints and both spawn contexts. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import hydra +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import scatter + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.insertion_data import InsertedAtoms + + +def _small_output_init(linear: nn.Linear, std: float) -> None: + if std < 0: + raise ValueError(f"output init std must be non-negative, got {std}") + if std == 0: + nn.init.zeros_(linear.weight) + else: + nn.init.normal_(linear.weight, mean=0.0, std=std) + if linear.bias is not None: + nn.init.zeros_(linear.bias) + + +def _logit(probability: float) -> float: + if not 0.0 < probability < 1.0: + raise ValueError( + "initial_probability must lie strictly between zero and one, " + f"got {probability}" + ) + return math.log(probability) - math.log1p(-probability) + + +def _inverse_softplus(rate: float) -> float: + if rate <= 0: + raise ValueError(f"initial_rate must be positive, got {rate}") + # log(expm1(x)) is the inverse of softplus. Avoid overflow for large x. + return rate if rate > 20.0 else math.log(math.expm1(rate)) + + +def _set_categorical_bias(linear: nn.Linear, probabilities: torch.Tensor) -> None: + probabilities = torch.as_tensor( + probabilities, device=linear.bias.device, dtype=linear.bias.dtype + ).flatten() + if probabilities.numel() != linear.out_features: + raise ValueError( + f"categorical prior has {probabilities.numel()} entries, expected " + f"{linear.out_features}" + ) + log_prior = probabilities.clamp_min(1e-8).log() + # Softmax is invariant to a shared constant. Centering avoids an unnecessary + # large common bias for vocabularies with very small probabilities. + linear.bias.data.copy_(log_prior - log_prior.mean()) + + +class SwiGLUReadoutAdapter(nn.Module): + """Pre-normalized, width-reducing SwiGLU feature adapter.""" + + def __init__(self, input_dim: int, output_dim: int): + super().__init__() + if input_dim <= 0 or output_dim <= 0: + raise ValueError( + f"adapter dimensions must be positive, got {input_dim}->{output_dim}" + ) + self.input_dim = input_dim + self.output_dim = output_dim + self.norm = nn.RMSNorm(input_dim, eps=1e-6) + self.gate_value = nn.Linear(input_dim, 2 * output_dim) + nn.init.xavier_uniform_(self.gate_value.weight) + nn.init.zeros_(self.gate_value.bias) + + def forward(self, features: torch.Tensor) -> torch.Tensor: + """Normalize features and apply a SiLU-gated readout adapter.""" + gate, value = self.gate_value(self.norm(features)).chunk(2, dim=-1) + return F.silu(gate) * value + + +class MultiHeadModule(nn.Module): + """Shared readout adapters with task-specific projections. + + Each head config retains ``input_dim`` and ``output_dim`` and additionally + selects an adapter ``group``. Heads of the same level/group reuse one adapter. + ``self.heads`` intentionally remains a ModuleDict of the final task projections: + shared training code uses ``heads[name].parameters()`` for DDP-safe dummies. + """ + + _LEVEL_CONFIG_KEYS = { + "node": "node_heads", + "graph": "graph_heads", + "edge": "edge_heads", + } + + def __init__( + self, + heads_configs: DictConfig, + output_init_std: float = 1e-3, + independent_adapters: bool = False, + ): + super().__init__() + self.output_init_std = float(output_init_std) + self.independent_adapters = bool(independent_adapters) + self.graph_aggregation = heads_configs.get("graph_aggregation", "mean") + self.adapters = nn.ModuleDict() + self.heads = nn.ModuleDict() + self.head_types: dict[str, str] = {} + self.head_adapter_keys: dict[str, str] = {} + self.has_graph_heads = False + self.has_edge_heads = False + + adapter_specs: dict[str, tuple[int, int]] = {} + for level, config_key in self._LEVEL_CONFIG_KEYS.items(): + level_configs = heads_configs.get(config_key) + if level_configs is None: + continue + if level == "graph": + self.has_graph_heads = True + elif level == "edge": + self.has_edge_heads = True + + for name, config in level_configs.items(): + input_dim = int(config.input_dim) + output_dim = int(config.output_dim) + adapter_dim = int(config.adapter_dim) + group = ( + name + if self.independent_adapters + else str(config.get("group", "default")) + ) + adapter_key = f"{level}_{group}" + spec = (input_dim, adapter_dim) + if adapter_key in adapter_specs and adapter_specs[adapter_key] != spec: + raise ValueError( + f"heads sharing adapter {adapter_key!r} disagree on " + "dimensions: " + f"{adapter_specs[adapter_key]} versus {spec}" + ) + adapter_specs[adapter_key] = spec + + projection = nn.Linear(adapter_dim, output_dim) + _small_output_init(projection, self.output_init_std) + if "initial_probability" in config: + nn.init.constant_( + projection.bias, _logit(float(config.initial_probability)) + ) + elif "initial_rate" in config: + nn.init.constant_( + projection.bias, _inverse_softplus(float(config.initial_rate)) + ) + + self.heads[name] = projection + self.head_types[name] = level + self.head_adapter_keys[name] = adapter_key + + for key, (input_dim, output_dim) in adapter_specs.items(): + self.adapters[key] = SwiGLUReadoutAdapter(input_dim, output_dim) + self.rate_heads = tuple(name for name in self.heads if "rate" in name) + self.edge_heads = tuple( + name for name, level in self.head_types.items() if level == "edge" + ) + + def initialize_class_priors( + self, + *, + atom: torch.Tensor | None = None, + charge: torch.Tensor | None = None, + edge: torch.Tensor | None = None, + ) -> None: + """Initialize categorical output biases from token probabilities.""" + priors = { + "atom_type_head": atom, + "charge_head": charge, + "edge_type_head": edge, + } + for name, probabilities in priors.items(): + if probabilities is not None and name in self.heads: + _set_categorical_bias(self.heads[name], probabilities) + + def _graph_features(self, h: torch.Tensor, batch: torch.Tensor) -> torch.Tensor: + if batch is None: + raise ValueError("batch is required when graph heads are configured") + num_graphs = int(batch.max()) + 1 if batch.numel() else 0 + if self.graph_aggregation == "mean": + return scatter(h, batch, dim=0, dim_size=num_graphs, reduce="mean") + if self.graph_aggregation == "sum": + return scatter(h, batch, dim=0, dim_size=num_graphs, reduce="sum") + raise ValueError(f"Unknown graph aggregation: {self.graph_aggregation}") + + def forward( + self, + h: torch.Tensor, + batch: torch.Tensor | None, + edge_attr: torch.Tensor | None = None, + full_to_triu: torch.Tensor | None = None, + ) -> dict[str, torch.Tensor]: + """Apply configured node, graph, and edge prediction heads.""" + sources: dict[str, torch.Tensor] = {"node": h} + if self.has_graph_heads: + sources["graph"] = self._graph_features(h, batch) + if self.has_edge_heads: + if edge_attr is None: + raise ValueError("edge_attr is required when edge heads are configured") + sources["edge"] = edge_attr + + adapter_cache: dict[str, torch.Tensor] = {} + outputs: dict[str, torch.Tensor] = {} + for name, projection in self.heads.items(): + adapter_key = self.head_adapter_keys[name] + if adapter_key not in adapter_cache: + level = self.head_types[name] + adapter_cache[adapter_key] = self.adapters[adapter_key](sources[level]) + outputs[name] = projection(adapter_cache[adapter_key]) + + for name in self.rate_heads: + outputs[name] = F.softplus(outputs[name]) + if full_to_triu is not None: + for name in self.edge_heads: + outputs[name] = outputs[name][full_to_triu] + return outputs + + +class UnimodalInsertionHead(nn.Module): + """Shared-adapter position/type head for the nearest pending insertion.""" + + def __init__( + self, + hidden_dim: int, + N_a: int, + N_c: int, + readout_dim: int, + output_init_std: float = 1e-3, + independent_adapters: bool = False, + ): + super().__init__() + self.N_a = int(N_a) + self.N_c = int(N_c) + self.independent_adapters = bool(independent_adapters) + if self.independent_adapters: + self.adapters = nn.ModuleDict( + { + name: SwiGLUReadoutAdapter(hidden_dim, readout_dim) + for name in ("atom", "charge", "offset") + } + ) + else: + self.adapter = SwiGLUReadoutAdapter(hidden_dim, readout_dim) + self.atom_projection = nn.Linear(readout_dim, self.N_a) + self.charge_projection = nn.Linear(readout_dim, self.N_c) + self.offset_projection = nn.Linear(readout_dim, 3) + _small_output_init(self.atom_projection, output_init_std) + _small_output_init(self.charge_projection, output_init_std) + # A small nonzero output lets an independent offset adapter receive + # gradients immediately while keeping the initial mean near the spawn. + _small_output_init(self.offset_projection, output_init_std) + + def initialize_class_priors( + self, + atom: torch.Tensor | None = None, + charge: torch.Tensor | None = None, + ) -> None: + """Initialize inserted-atom and charge biases from token priors.""" + if atom is not None: + _set_categorical_bias(self.atom_projection, atom) + if charge is not None: + _set_categorical_bias(self.charge_projection, charge) + + def forward( + self, + h: torch.Tensor, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + ) -> dict[str, torch.Tensor]: + """Predict one insertion location and categorical logits per node.""" + del edge_index + if self.independent_adapters: + atom_features = self.adapters["atom"](h) + charge_features = self.adapters["charge"](h) + offset_features = self.adapters["offset"](h) + else: + features = self.adapter(h) + atom_features = charge_features = offset_features = features + return { + "mu": x + self.offset_projection(offset_features), + "a_logits": self.atom_projection(atom_features), + "c_logits": self.charge_projection(charge_features), + } + + +class GMMInsertionHead(nn.Module): + """Compact, numerically bounded mixture readout for insertion content. + + Cartesian component means are residual offsets from the spawning atom. A + shared normalized adapter feeds independent projections for mixture weights, scales, + categorical content and component offsets. In particular, scales are + bounded in log space: this prevents both variance collapse (exploding NLL) + and an arbitrarily broad component that can cheaply absorb several targets. + """ + + def __init__( + self, + hidden_dim: int, + num_components: int, + N_a: int, + N_c: int, + readout_dim: int | None = None, + output_init_std: float = 1e-3, + sigma_min: float = 0.05, + sigma_max: float = 2.0, + sigma_init: float = 0.5, + ): + super().__init__() + if readout_dim is None: + readout_dim = hidden_dim + if not 0.0 < sigma_min < sigma_init < sigma_max: + raise ValueError( + "GMM scales must satisfy 0 < sigma_min < sigma_init < " + f"sigma_max, got {sigma_min}, {sigma_init}, {sigma_max}" + ) + + self.num_components = int(num_components) + self.N_a = int(N_a) + self.N_c = int(N_c) + self.sigma_min = float(sigma_min) + self.sigma_max = float(sigma_max) + + self.adapter = SwiGLUReadoutAdapter(hidden_dim, readout_dim) + self.mixture_projection = nn.Linear(readout_dim, self.num_components) + self.scale_projection = nn.Linear(readout_dim, self.num_components) + self.atom_projection = nn.Linear(readout_dim, self.num_components * self.N_a) + self.charge_projection = nn.Linear(readout_dim, self.num_components * self.N_c) + self.offset_projection = nn.Linear(readout_dim, self.num_components * 3) + + # Uniform component weights, exact categorical marginals and a fixed, + # useful initial scale make the first NLL finite and predictable. Small + # independent offset weights break component symmetry without initially + # displacing the means much. + _small_output_init(self.mixture_projection, 0.0) + _small_output_init(self.scale_projection, 0.0) + _small_output_init(self.atom_projection, 0.0) + _small_output_init(self.charge_projection, 0.0) + _small_output_init(self.offset_projection, output_init_std) + + log_min = math.log(self.sigma_min) + log_max = math.log(self.sigma_max) + init_fraction = (math.log(sigma_init) - log_min) / (log_max - log_min) + nn.init.constant_(self.scale_projection.bias, _logit(init_fraction)) + + def initialize_class_priors( + self, + atom: torch.Tensor | None = None, + charge: torch.Tensor | None = None, + ) -> None: + """Initialize every component with the empirical categorical marginals.""" + if atom is not None: + bias = self.atom_projection.bias + atom = torch.as_tensor(atom, device=bias.device, dtype=bias.dtype) + if atom.numel() != self.N_a: + raise ValueError( + f"atom prior has {atom.numel()} entries, expected {self.N_a}" + ) + atom_logits = atom.clamp_min(1e-8).log() + atom_logits = atom_logits - atom_logits.mean() + bias.data.copy_(atom_logits.repeat(self.num_components)) + if charge is not None: + bias = self.charge_projection.bias + charge = torch.as_tensor(charge, device=bias.device, dtype=bias.dtype) + if charge.numel() != self.N_c: + raise ValueError( + f"charge prior has {charge.numel()} entries, expected {self.N_c}" + ) + charge_logits = charge.clamp_min(1e-8).log() + charge_logits = charge_logits - charge_logits.mean() + bias.data.copy_(charge_logits.repeat(self.num_components)) + + def forward( + self, + h: torch.Tensor, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + ) -> dict[str, torch.Tensor]: + """Predict typed Gaussian mixture parameters for each spawning node.""" + del edge_index + n_nodes = h.shape[0] + features = self.adapter(h) + scale_fraction = torch.sigmoid(self.scale_projection(features)) + log_sigma = math.log(self.sigma_min) + scale_fraction * ( + math.log(self.sigma_max) - math.log(self.sigma_min) + ) + return { + "pi": F.softmax(self.mixture_projection(features), dim=-1), + "mu": x.unsqueeze(1) + + self.offset_projection(features).view(n_nodes, self.num_components, 3), + "sigma": log_sigma.exp(), + "a_probs": F.softmax( + self.atom_projection(features).view( + n_nodes, self.num_components, self.N_a + ), + dim=-1, + ), + "c_probs": F.softmax( + self.charge_projection(features).view( + n_nodes, self.num_components, self.N_c + ), + dim=-1, + ), + } + + +class InsertionEdgeHead(nn.Module): + """Compact insertion-edge head with a symmetric inserted-pair branch. + + Inserted-to-existing bonds retain their meaningful role asymmetry. For a bond + between two simultaneously inserted atoms, both endpoints are encoded with the + same function and combined through sum/absolute-difference features. + """ + + def __init__( + self, + hidden_dim: int, + n_edge_types: int, + n_atom_types: int, + n_charge_types: int, + readout_dim: int, + rbf_embedding_args: DictConfig | None = None, + output_init_std: float = 1e-3, + ): + super().__init__() + if rbf_embedding_args is None: + raise ValueError("rbf_embedding_args is required") + self.hidden_dim = hidden_dim + self.n_edge_types = n_edge_types + self.n_atom_types = n_atom_types + self.n_charge_types = n_charge_types + self.rbf_embedding = hydra.utils.instantiate(rbf_embedding_args) + self._rbf_has_dropout = float(rbf_embedding_args.get("dropout", 0.0)) > 0 + + dist_feat_dim = int( + rbf_embedding_args.get("out_dim", rbf_embedding_args.get("num_rbf", 16)) + ) + inserted_attr_dim = n_atom_types + n_charge_types + existing_input_dim = ( + 2 * hidden_dim + 2 * dist_feat_dim + n_atom_types + inserted_attr_dim + ) + self.edge_mlp = self._make_decoder( + existing_input_dim, readout_dim, n_edge_types, output_init_std + ) + + endpoint_input_dim = hidden_dim + dist_feat_dim + inserted_attr_dim + self.inserted_endpoint = nn.Sequential( + nn.LayerNorm(endpoint_input_dim), + nn.Linear(endpoint_input_dim, readout_dim), + nn.SiLU(), + ) + pair_input_dim = 2 * readout_dim + dist_feat_dim + self.inserted_pair_decoder = self._make_decoder( + pair_input_dim, readout_dim, n_edge_types, output_init_std + ) + nn.init.xavier_uniform_(self.inserted_endpoint[1].weight) + nn.init.zeros_(self.inserted_endpoint[1].bias) + + @staticmethod + def _make_decoder( + input_dim: int, + hidden_dim: int, + output_dim: int, + output_init_std: float, + ) -> nn.Sequential: + decoder = nn.Sequential( + nn.LayerNorm(input_dim), + nn.Linear(input_dim, hidden_dim), + nn.SiLU(), + nn.Linear(hidden_dim, output_dim), + ) + nn.init.kaiming_normal_(decoder[1].weight, nonlinearity="relu") + nn.init.zeros_(decoder[1].bias) + _small_output_init(decoder[-1], output_init_std) + return decoder + + def initialize_edge_prior(self, probabilities: torch.Tensor) -> None: + """Initialize both insertion-bond decoders from bond probabilities.""" + _set_categorical_bias(self.edge_mlp[-1], probabilities) + _set_categorical_bias(self.inserted_pair_decoder[-1], probabilities) + + def _inserted_endpoint_features( + self, + h: torch.Tensor, + x: torch.Tensor, + spawn_idx: torch.Tensor, + inserted: InsertedAtoms, + ) -> torch.Tensor: + atom = F.one_hot(inserted.a.long(), num_classes=self.n_atom_types).to( + device=h.device, dtype=h.dtype + ) + charge = F.one_hot(inserted.c.long(), num_classes=self.n_charge_types).to( + device=h.device, dtype=h.dtype + ) + distance = (inserted.x.detach() - x.detach()[spawn_idx]).norm(dim=-1) + distance_features = self.rbf_embedding(distance.clamp_min(1e-6)) + return self.inserted_endpoint( + torch.cat([h[spawn_idx], distance_features, atom, charge], dim=-1) + ) + + def forward_inserted_to_inserted( + self, + h: torch.Tensor, + x: torch.Tensor, + spawn_src_idx: torch.Tensor, + spawn_dst_idx: torch.Tensor, + src: InsertedAtoms, + dst: InsertedAtoms, + ) -> torch.Tensor: + """Predict bond logits between pairs of future atoms.""" + if spawn_src_idx.numel() == 0: + return torch.empty((0, self.n_edge_types), device=h.device, dtype=h.dtype) + if spawn_src_idx.shape != spawn_dst_idx.shape: + raise ValueError( + "spawn_src_idx and spawn_dst_idx must have identical shape, got " + f"{tuple(spawn_src_idx.shape)} and {tuple(spawn_dst_idx.shape)}" + ) + + src_features = self._inserted_endpoint_features(h, x, spawn_src_idx, src) + dst_features = self._inserted_endpoint_features(h, x, spawn_dst_idx, dst) + scale = math.sqrt(0.5) + pair = torch.cat( + [ + scale * (src_features + dst_features), + scale * (src_features - dst_features).abs(), + self.rbf_embedding( + (src.x.detach() - dst.x.detach()).norm(dim=-1).clamp_min(1e-6) + ), + ], + dim=-1, + ) + return self.inserted_pair_decoder(pair) + + def _prepare_edge_features( + self, + h_spawn: torch.Tensor, + h_existing: torch.Tensor, + spawn_pos: torch.Tensor, + existing_pos: torch.Tensor, + existing_atom_idx: torch.Tensor, + ins_pos: torch.Tensor, + ins_a: torch.Tensor, + ins_c: torch.Tensor, + insertion_index: torch.Tensor | None = None, + ) -> torch.Tensor: + """Build edge-MLP features for insertion edge prediction.""" + ins_atom_type = F.one_hot(ins_a.long(), num_classes=self.n_atom_types).to( + dtype=h_spawn.dtype, device=h_spawn.device + ) + ins_charge_type = F.one_hot(ins_c.long(), num_classes=self.n_charge_types).to( + dtype=h_spawn.dtype, device=h_spawn.device + ) + + dist_to_spawn = torch.norm(ins_pos - spawn_pos, dim=-1).clamp(min=1e-6) + if insertion_index is not None: + # At inference the spawn-side features are constant for all candidate + # existing endpoints. Embed them once per insertion, then expand. + dist_spawn_features = self.rbf_embedding(dist_to_spawn)[insertion_index] + h_spawn = h_spawn[insertion_index] + ins_atom_type = ins_atom_type[insertion_index] + ins_charge_type = ins_charge_type[insertion_index] + ins_pos = ins_pos[insertion_index] + + dist_to_existing = torch.norm(ins_pos - existing_pos, dim=-1).clamp(min=1e-6) + if insertion_index is None and not (self.training and self._rbf_has_dropout): + distance_features = self.rbf_embedding( + torch.cat([dist_to_spawn, dist_to_existing]) + ) + dist_spawn_features, dist_existing_features = distance_features.chunk(2) + else: + if insertion_index is None: + dist_spawn_features = self.rbf_embedding(dist_to_spawn) + dist_existing_features = self.rbf_embedding(dist_to_existing) + + existing_atom_type = F.one_hot( + existing_atom_idx.long(), num_classes=self.n_atom_types + ).to(dtype=h_spawn.dtype, device=h_spawn.device) + + return torch.cat( + [ + h_spawn, + h_existing, + dist_spawn_features, + existing_atom_type, + dist_existing_features, + ins_atom_type, + ins_charge_type, + ], + dim=-1, + ) + + def forward( + self, + h: torch.Tensor, + x: torch.Tensor, + node_atom_types: torch.Tensor, + spawn_node_idx: torch.Tensor, + existing_node_idx: torch.Tensor, + ins: InsertedAtoms, + ) -> torch.Tensor: + """Predict bond logits from future atoms to existing atoms.""" + if spawn_node_idx.numel() == 0: + return torch.empty((0, self.n_edge_types), device=h.device, dtype=h.dtype) + + # Use fixed insertion attributes to decouple edge prediction from GMM sampling. + x_detached = x.detach() + edge_features = self._prepare_edge_features( + h_spawn=h[spawn_node_idx], + h_existing=h[existing_node_idx], + spawn_pos=x_detached[spawn_node_idx], + existing_pos=x_detached[existing_node_idx], + existing_atom_idx=node_atom_types[existing_node_idx], + ins_pos=ins.x.detach(), + ins_a=ins.a, + ins_c=ins.c, + ) + + edge_logits = self.edge_mlp(edge_features) + + return edge_logits + + @staticmethod + def _insertion_edge_pairs( + insertion_indices: torch.Tensor, batch: torch.Tensor, node_counts: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Pair every insertion with every node sharing its graph. + + ``batch`` must be graph-contiguous, as every :class:`ChemBatch` is. + Returns ``(spawn_idx, existing_idx, local)`` where ``spawn_idx`` / + ``existing_idx`` are global node indices and ``local`` indexes the + per-insertion tensors. Row-major over insertions then nodes, matching the + original per-insertion loop order (downstream code relies on it). + """ + insertion_graphs = batch[insertion_indices] + pair_counts = node_counts[insertion_graphs] + num_pairs = int(pair_counts.sum()) + local = torch.arange( + insertion_indices.numel(), device=batch.device + ).repeat_interleave(pair_counts, output_size=num_pairs) + graph_offsets = node_counts.cumsum(0) - node_counts + pair_offsets = pair_counts.cumsum(0) - pair_counts + within_graph = ( + torch.arange(num_pairs, device=batch.device) - pair_offsets[local] + ) + existing_idx = graph_offsets[insertion_graphs[local]] + within_graph + return insertion_indices[local], existing_idx, local + + def predict_inserted_to_existing( + self, + h: torch.Tensor, + x: torch.Tensor, + node_atom_types: torch.Tensor, + batch: torch.Tensor, + insertion_indices: torch.Tensor, + node_counts: torch.Tensor, + ins: InsertedAtoms, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Predict edges for all insertions to all other nodes in the same graph. + + This is used during inference to get edge predictions. + + Args: + h: [N, hidden_dim] - Node features + x: [N, 3] - Node positions + node_atom_types: [N] - Atom type index for each current-state node + batch: [N] - Batch assignment + insertion_indices: [N_ins] - Selected spawning node indices. + node_counts: [B] - Current node counts before deletion. + ins: Inserted-atom features aligned with ``insertion_indices``. + + Returns: + spawn_idx: [E_ins] - Spawn node indices + existing_idx: [E_ins] - Existing endpoint node indices + edge_logits: [E_ins, n_edge_types] - Edge type logits + """ + device = h.device + + if insertion_indices.numel() == 0: + return ( + torch.empty(0, dtype=torch.long, device=device), + torch.empty(0, dtype=torch.long, device=device), + torch.empty(0, self.n_edge_types, device=device), + ) + + # Pair each insertion with every node in its graph (including the spawn + # node). ``local`` indexes the per-insertion inserted-atom tensors. + spawn_idx, existing_idx, local = self._insertion_edge_pairs( + insertion_indices, batch, node_counts + ) + + # Compute insertion-constant features once rather than once for every + # candidate existing endpoint. + x_detached = x.detach() + edge_features = self._prepare_edge_features( + h_spawn=h[insertion_indices], + h_existing=h[existing_idx], + spawn_pos=x_detached[insertion_indices], + existing_pos=x_detached[existing_idx], + existing_atom_idx=node_atom_types[existing_idx], + ins_pos=ins.x.detach(), + ins_a=ins.a, + ins_c=ins.c, + insertion_index=local, + ) + edge_logits = self.edge_mlp(edge_features) + + return spawn_idx, existing_idx, edge_logits + + +class InsertionEdgePredictorMixin: + """Prediction glue shared by DiT models with insertion-edge heads.""" + + heads: MultiHeadModule + insertion_head: nn.Module + ins_edge_head: InsertionEdgeHead + + def _assemble_outputs( + self, + h: torch.Tensor, + x_out: torch.Tensor, + e_out_triu: torch.Tensor, + full_to_triu: torch.Tensor, + x: torch.Tensor, + batch: torch.Tensor, + edge_index_tuple: tuple[torch.Tensor, torch.Tensor], + ) -> dict[str, object]: + out_dict = self.heads(h, batch, e_out_triu, full_to_triu=full_to_triu) + out_dict["pos_head"] = x_out + out_dict["insertion_head"] = self.insertion_head(h, x, edge_index_tuple) + out_dict["h_latent"] = h + return out_dict + + def predict_inserted_to_existing_edges( + self, + mols_t, + out_dict: dict[str, object], + spawn_node_idx: torch.Tensor, + existing_node_idx: torch.Tensor, + ins: InsertedAtoms, + ) -> torch.Tensor: + """Decode future-to-current bonds using the cached node latents.""" + return self.ins_edge_head( + h=out_dict["h_latent"], + x=mols_t.x, + node_atom_types=mols_t.a, + spawn_node_idx=spawn_node_idx, + existing_node_idx=existing_node_idx, + ins=ins, + ) + + def predict_inserted_to_inserted_edges( + self, + mols_t, + out_dict: dict[str, object], + spawn_src_idx: torch.Tensor, + spawn_dst_idx: torch.Tensor, + src: InsertedAtoms, + dst: InsertedAtoms, + ) -> torch.Tensor: + """Decode future-to-future bonds using the cached node latents.""" + return self.ins_edge_head.forward_inserted_to_inserted( + h=out_dict["h_latent"], + x=mols_t.x, + spawn_src_idx=spawn_src_idx, + spawn_dst_idx=spawn_dst_idx, + src=src, + dst=dst, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/kernels.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/kernels.py new file mode 100644 index 0000000000000000000000000000000000000000..592fbbe70160531c527c5eea0dd6dce1a90b397c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/kernels.py @@ -0,0 +1,375 @@ +# Triton imports are deferred so CPU-only installations can import the model. +# ruff: noqa: PLC0415 + +"""Fused CUDA operations shared by the model layers. + +Keep Triton definitions, registration and eligibility checks here; ordinary +attention and backbone code use these helpers with native PyTorch fallbacks. +Triton is imported only when the corresponding CUDA implementation is loaded. +""" + +from __future__ import annotations + +import os +from functools import cache + +import torch +import torch.nn as nn + + +@cache +def _load_fused_qkv_packing(): + """Normalize Q/K and write the padded attention layout in one CUDA launch.""" + import triton + import triton.language as tl + from torch.library import triton_op, wrap_triton + + @triton.jit + def pack_kernel( + QKV, + QW, + KW, + OFFSETS, + OUT, + N, + HEADS: tl.constexpr, + D: tl.constexpr, + EPS: tl.constexpr, + S0: tl.constexpr, + S1: tl.constexpr, + S2: tl.constexpr, + S3: tl.constexpr, + BLOCK: tl.constexpr, + ): + graph = tl.program_id(0) + row = tl.program_id(1) * 8 + tl.arange(0, 8) + head = tl.program_id(2) + start = tl.load(OFFSETS + graph) + end = tl.load(OFFSETS + graph + 1) + d = tl.arange(0, BLOCK) + valid = (start + row[:, None] < end) & (d[None, :] < D) + offset = (start + row[:, None]) * S0 + head * S2 + d[None, :] * S3 + q = tl.load(QKV + offset, valid, other=0).to(tl.float32) + k = tl.load(QKV + offset + S1, valid, other=0).to(tl.float32) + v = tl.load(QKV + offset + 2 * S1, valid, other=0) + q = q * tl.rsqrt(tl.sum(q * q, 1) / D + EPS)[:, None] + k = k * tl.rsqrt(tl.sum(k * k, 1) / D + EPS)[:, None] + q = q * tl.load(QW + d, d < D, other=0).to(tl.float32)[None, :] + k = k * tl.load(KW + d, d < D, other=0).to(tl.float32)[None, :] + out = ((graph * N + row[:, None]) * 3 * HEADS + head) * D + d[None, :] + store = (row[:, None] < N) & (d[None, :] < D) + tl.store(OUT + out, q, store) + tl.store(OUT + out + HEADS * D, k, store) + tl.store(OUT + out + 2 * HEADS * D, v, store) + + @triton_op("morph::pack_normalized_qkv", mutates_args={}) + def pack( + qkv: torch.Tensor, + q_weight: torch.Tensor, + k_weight: torch.Tensor, + offsets: torch.Tensor, + max_seqlen: int, + eps: float, + ) -> torch.Tensor: + batch_size = offsets.numel() - 1 + heads, dim = qkv.shape[2:] + out = qkv.new_empty((batch_size, max_seqlen, 3, heads, dim)) + wrap_triton(pack_kernel)[(batch_size, triton.cdiv(max_seqlen, 8), heads)]( + qkv, + q_weight, + k_weight, + offsets, + out, + max_seqlen, + heads, + dim, + eps, + *qkv.stride(), + triton.next_power_of_2(dim), + num_warps=4, + enable_fp_fusion=False, + ) + return out + + return pack + + +_pack_normalized_qkv = _load_fused_qkv_packing() if torch.cuda.is_available() else None + + +def pack_normalized_qkv(qkv, offsets, max_seqlen, q_norm, k_norm): + """Return fused padded QKV, or ``None`` when native packing is required. + + The caller must supply graph-contiguous packed QKV and the corresponding + cumulative lengths. CPU, autograd, and unsupported normalization layouts + use the ordinary PyTorch path in attention.py. + """ + supported = ( + _pack_normalized_qkv is not None + and qkv.is_cuda + and not torch.is_grad_enabled() + and qkv.dtype in (torch.float32, torch.bfloat16) + and isinstance(q_norm, nn.RMSNorm) + and isinstance(k_norm, nn.RMSNorm) + and q_norm.weight is not None + and k_norm.weight is not None + and q_norm.weight.dtype == torch.float32 + and k_norm.weight.dtype == torch.float32 + and q_norm.normalized_shape == k_norm.normalized_shape == (qkv.shape[-1],) + and q_norm.weight.is_contiguous() + and k_norm.weight.is_contiguous() + and q_norm.eps is not None + and q_norm.eps == k_norm.eps + ) + if not supported: + return None + return _pack_normalized_qkv( + qkv, q_norm.weight, k_norm.weight, offsets, max_seqlen, q_norm.eps + ) + + +def graph_broadcast(values, batch_idx, offsets=None): + return values[batch_idx] + + +@cache +def _load_fused_graph_broadcast(): + """Load the first-order CUDA graph-condition reduction once. + + Keep Triton optional for native indexing and CPU imports. The reduction + uses FP32 accumulation with a different order from native indexing. + """ + import triton + import triton.language as tl + from torch.library import triton_op, wrap_triton + + @triton.jit + def _sum_graph_rows( + grad, + offsets, + output, + width: tl.constexpr, + row_stride: tl.constexpr, + col_stride: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_D: tl.constexpr, + ): + graph = tl.program_id(0) + cols = tl.program_id(1) * BLOCK_D + tl.arange(0, BLOCK_D) + start = tl.load(offsets + graph) + end = tl.load(offsets + graph + 1) + rows = start + tl.arange(0, BLOCK_N) + accum = tl.full((BLOCK_N, BLOCK_D), 0, tl.float32) + # Fixed-size tiles cover arbitrary graph lengths without atom bucketing or + # specializing the compiled model for each maximum atom count. + for tile_start in range(start, end, BLOCK_N): + rows = tile_start + tl.arange(0, BLOCK_N) + values = tl.load( + grad + rows[:, None] * row_stride + cols[None, :] * col_stride, + (rows[:, None] < end) & (cols[None, :] < width), + other=0, + ) + accum += values.to(tl.float32) + total = tl.sum(accum, axis=0) + tl.store(output + graph * width + cols, total, cols < width) + + @triton_op("morph::sum_graph_rows", mutates_args={}) + def sum_graph_rows(grad: torch.Tensor, offsets: torch.Tensor) -> torch.Tensor: + out = torch.empty( + (offsets.numel() - 1, grad.shape[1]), device=grad.device, dtype=grad.dtype + ) + wrap_triton(_sum_graph_rows)[(out.shape[0], triton.cdiv(grad.shape[1], 64))]( + grad, + offsets, + out, + grad.shape[1], + grad.stride(0), + grad.stride(1), + 32, + 64, + ) + return out + + class TritonBroadcast(torch.autograd.Function): + @staticmethod + def forward(ctx, values, batch_idx, offsets): + ctx.save_for_backward(offsets) + return values[batch_idx] + + @staticmethod + def backward(ctx, grad): + (offsets,) = ctx.saved_tensors + return sum_graph_rows(grad, offsets), None, None + + def graph_broadcast(values, batch_idx, offsets=None): + if ( + offsets is None + or not values.is_cuda + or not torch.is_grad_enabled() + or values.dtype not in (torch.float32, torch.bfloat16) + ): + return values[batch_idx] + return TritonBroadcast.apply(values, batch_idx, offsets) + + return graph_broadcast + + +if os.environ.get("MORPH_FUSED_BROADCAST", "1") == "1" and torch.cuda.is_available(): + graph_broadcast = _load_fused_graph_broadcast() + + +@cache +def _load_edge_normalization(): + """Build symmetric pair features and normalize without materializing a cat.""" + import triton + import triton.language as tl + from torch.library import triton_op, wrap_triton + + @triton.jit + def kernel( + P, + SRC, + DST, + EDGE, + RBF, + W, + B, + OUT, + E, + PS0: tl.constexpr, + PS1: tl.constexpr, + SS: tl.constexpr, + DS: tl.constexpr, + ES0: tl.constexpr, + ES1: tl.constexpr, + RS0: tl.constexpr, + RS1: tl.constexpr, + PD: tl.constexpr, + ED: tl.constexpr, + RD: tl.constexpr, + EPS: tl.constexpr, + BLOCK: tl.constexpr, + ): + row = tl.program_id(0) * 4 + tl.arange(0, 4) + col = tl.arange(0, BLOCK) + src = tl.load(SRC + row * SS, row < E, other=0) + dst = tl.load(DST + row * DS, row < E, other=0) + pair_col = col % PD + pair_mask = (row[:, None] < E) & (col[None, :] < 2 * PD) + a = tl.load( + P + src[:, None] * PS0 + pair_col[None, :] * PS1, pair_mask, other=0 + ) + b = tl.load( + P + dst[:, None] * PS0 + pair_col[None, :] * PS1, pair_mask, other=0 + ) + pair = tl.where( + col[None, :] < PD, + a.to(tl.float32) + b.to(tl.float32), + tl.abs(a.to(tl.float32) - b.to(tl.float32)), + ) + # Match the native pair arithmetic's intermediate BF16 rounding. + pair = ( + (pair.to(a.dtype).to(tl.float32) * 0.7071067811865476) + .to(a.dtype) + .to(tl.float32) + ) + edge = tl.load( + EDGE + row[:, None] * ES0 + (col[None, :] - 2 * PD) * ES1, + (row[:, None] < E) + & (col[None, :] >= 2 * PD) + & (col[None, :] < 2 * PD + ED), + other=0, + ).to(tl.float32) + rbf = tl.load( + RBF + row[:, None] * RS0 + (col[None, :] - 2 * PD - ED) * RS1, + (row[:, None] < E) + & (col[None, :] >= 2 * PD + ED) + & (col[None, :] < 2 * PD + ED + RD), + other=0, + ).to(tl.float32) + width = 2 * PD + ED + RD + value = tl.where( + col[None, :] < 2 * PD, pair, tl.where(col[None, :] < 2 * PD + ED, edge, rbf) + ) + value = tl.where(col[None, :] < width, value, 0.0) + mean = tl.sum(value, 1) / width + centered = tl.where(col[None, :] < width, value - mean[:, None], 0.0) + var = tl.sum(centered * centered, 1) / width + norm = centered * tl.rsqrt(var + EPS)[:, None] + weight = tl.load(W + col, col < width, other=0).to(tl.float32) + bias = tl.load(B + col, col < width, other=0).to(tl.float32) + tl.store( + OUT + row[:, None] * width + col[None, :], + norm * weight + bias, + (row[:, None] < E) & (col[None, :] < width), + ) + + @triton_op("morph::edge_normalization", mutates_args={}) + def normalize( + p: torch.Tensor, + src: torch.Tensor, + dst: torch.Tensor, + edge: torch.Tensor, + rbf: torch.Tensor, + w: torch.Tensor, + b: torch.Tensor, + eps: float, + ) -> torch.Tensor: + width = 2 * p.shape[1] + edge.shape[1] + rbf.shape[1] + # CUDA autocast runs LayerNorm in FP32; the following linear casts it. + out = torch.empty((src.numel(), width), dtype=torch.float32, device=p.device) + wrap_triton(kernel)[(triton.cdiv(src.numel(), 4),)]( + p, + src, + dst, + edge, + rbf, + w, + b, + out, + src.numel(), + *p.stride(), + src.stride(0), + dst.stride(0), + *edge.stride(), + *rbf.stride(), + p.shape[1], + edge.shape[1], + rbf.shape[1], + eps, + triton.next_power_of_2(width), + num_warps=4, + enable_fp_fusion=False, + ) + return out + + return normalize + + +_edge_normalization = _load_edge_normalization() if torch.cuda.is_available() else None + + +def normalized_edge_features(p, edge_index, edge, distance, norm): + """Return fused CUDA inference features, or ``None`` for the native path.""" + if ( + _edge_normalization is None + or not p.is_cuda + or torch.is_grad_enabled() + or any( + x.dtype not in (torch.float32, torch.bfloat16) for x in (p, edge, distance) + ) + or not isinstance(norm, nn.LayerNorm) + or norm.weight is None + or norm.bias is None + or norm.weight.dtype != torch.float32 + or norm.bias.dtype != torch.float32 + or not norm.weight.is_contiguous() + or not norm.bias.is_contiguous() + or norm.normalized_shape + != (2 * p.shape[1] + edge.shape[1] + distance.shape[1],) + or (p.dtype != torch.float32 and not torch.is_autocast_enabled("cuda")) + ): + return None + return _edge_normalization( + p, *edge_index, edge, distance, norm.weight, norm.bias, norm.eps + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/lora.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/lora.py new file mode 100644 index 0000000000000000000000000000000000000000..784a0da934778ebc5a23ba49bfd690e8dffc619a --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/layers/lora.py @@ -0,0 +1,182 @@ +"""Low-rank adapters for DiT linear projections.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch +import torch.nn as nn +import torch.nn.functional as F + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class LoRALinear(nn.Linear): + """A linear layer with a trainable low-rank residual. + + Keeping ``weight`` and ``bias`` directly on the layer preserves the state + dict layout of :class:`torch.nn.Linear`, so ordinary DiT checkpoints load + without key rewriting. The residual starts at exactly zero. + """ + + def __init__( + self, + in_features: int, + out_features: int, + *, + rank: int, + alpha: float, + dropout: float = 0.0, + bias: bool = True, + device: torch.device | str | None = None, + dtype: torch.dtype | None = None, + ): + if rank <= 0: + raise ValueError(f"LoRA rank must be positive, got {rank}") + if alpha <= 0: + raise ValueError(f"LoRA alpha must be positive, got {alpha}") + if not 0.0 <= dropout < 1.0: + raise ValueError(f"LoRA dropout must be in [0, 1), got {dropout}") + super().__init__( + in_features, + out_features, + bias=bias, + device=device, + dtype=dtype, + ) + self.rank = int(rank) + self.alpha = float(alpha) + self.scaling = self.alpha / self.rank + self.lora_dropout = nn.Dropout(dropout) + self.lora_A = nn.Parameter( + torch.empty(self.rank, in_features, device=device, dtype=dtype) + ) + self.lora_B = nn.Parameter( + torch.empty(out_features, self.rank, device=device, dtype=dtype) + ) + self._merged = False + self.reset_lora_parameters() + + @classmethod + def from_linear( + cls, + linear: nn.Linear, + *, + rank: int, + alpha: float, + dropout: float = 0.0, + freeze_base: bool = True, + ) -> LoRALinear: + """Wrap ``linear`` without copying or renaming its base parameters.""" + adapter = cls( + linear.in_features, + linear.out_features, + rank=rank, + alpha=alpha, + dropout=dropout, + bias=linear.bias is not None, + device=linear.weight.device, + dtype=linear.weight.dtype, + ) + adapter.weight = linear.weight + adapter.bias = linear.bias + if freeze_base: + adapter.weight.requires_grad_(False) + if adapter.bias is not None: + adapter.bias.requires_grad_(False) + return adapter + + def reset_lora_parameters(self) -> None: + """Initialize the adapter so its initial residual is exactly zero.""" + nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) + nn.init.zeros_(self.lora_B) + + def lora_delta(self) -> torch.Tensor: + """Return the scaled low-rank weight update in base-weight layout.""" + return (self.lora_B @ self.lora_A) * self.scaling + + @torch.no_grad() + def merge_lora_(self) -> LoRALinear: + """Fold the adapter into the base weight for inference.""" + if not self._merged: + self.weight.add_(self.lora_delta().to(dtype=self.weight.dtype)) + self._merged = True + return self + + @torch.no_grad() + def unmerge_lora_(self) -> LoRALinear: + """Restore the separate base and adapter weights.""" + if self._merged: + self.weight.sub_(self.lora_delta().to(dtype=self.weight.dtype)) + self._merged = False + return self + + def _save_to_state_dict(self, destination, prefix, keep_vars): + # Checkpoints always store the unmerged base weight plus the two adapter + # matrices. This makes save/load independent of inference merge state. + super()._save_to_state_dict(destination, prefix, keep_vars) + if self._merged: + weight = self.weight - self.lora_delta().to(dtype=self.weight.dtype) + destination[f"{prefix}weight"] = weight if keep_vars else weight.detach() + + def _load_from_state_dict( + self, + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + self.unmerge_lora_() + super()._load_from_state_dict( + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Apply the base projection and the unmerged low-rank residual.""" + output = F.linear(x, self.weight, self.bias) + if self._merged: + return output + update = F.linear(F.linear(self.lora_dropout(x), self.lora_A), self.lora_B) + return output + update * self.scaling + + +def iter_lora_layers(module: nn.Module) -> Iterator[LoRALinear]: + """Yield all LoRA layers below ``module`` exactly once.""" + for child in module.modules(): + if isinstance(child, LoRALinear): + yield child + + +def set_lora_trainable(module: nn.Module, trainable: bool = True) -> int: + """Set adapter gradients and return the number of adapter parameters.""" + count = 0 + for layer in iter_lora_layers(module): + layer.lora_A.requires_grad_(trainable) + layer.lora_B.requires_grad_(trainable) + count += layer.lora_A.numel() + layer.lora_B.numel() + return count + + +def merge_lora_(module: nn.Module) -> nn.Module: + """Merge every low-rank adapter below ``module``.""" + for layer in iter_lora_layers(module): + layer.merge_lora_() + return module + + +def unmerge_lora_(module: nn.Module) -> nn.Module: + """Unmerge every low-rank adapter below ``module``.""" + for layer in iter_lora_layers(module): + layer.unmerge_lora_() + return module diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40c82d6620249d9e0eed505b47c19d01ca8a1fd0 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__init__.py @@ -0,0 +1,7 @@ +"""PyTorch-Lightning training modules. + +:mod:`base` holds :class:`LightningModuleRates`, assembled from per-concern +mixins (:mod:`_setup`, :mod:`_training`, :mod:`_validation`, :mod:`_sampling`, +:mod:`_optimizer`). The repository's models all use the DiT backbone. +Task-specific finetuning modules live in :mod:`morph.model.finetune`. +""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35c1975b5a40ecb2e26c26ebb648ff2d94cf0061 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_optimizer.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_optimizer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..854962698f8442b7a7b450df8710f7acd455bb43 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_optimizer.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_sampling.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_sampling.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2392ec912285d745a8cface115616b1b1607418f Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_sampling.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_setup.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_setup.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..768943e66f827a7a66d5db7f26a553c07a234614 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_setup.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_training.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_training.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..00d9fb83551c1172b5b2d5e45a5d443708df18c2 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_training.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_validation.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_validation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..16ccb1b73b0ac343e9a16f496d25b01727b9f23e Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/_validation.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/base.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..77d6bb76fb51c1d0ee63c9292855d88fdc23e374 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/__pycache__/base.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_optimizer.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_optimizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f4182a65333d9762218383c04a238c19cb645f3b --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_optimizer.py @@ -0,0 +1,476 @@ +"""Optimizer construction, routing, stepping, and gradient diagnostics.""" + +from __future__ import annotations + +from contextlib import nullcontext + +import hydra +import torch +import torch.distributed as dist +from lightning.pytorch.utilities import grad_norm +from omegaconf import OmegaConf +from torch.optim._muon import _adjust_lr, _zeropower_via_newtonschulz + +from morph._vendor.muon import MuonWithAuxAdam, SingleDeviceMuonWithAuxAdam + + +class CudaGraphMuon(torch.optim.Muon): + """Replay native momentum/orthogonalization kernels with fixed input buffers. + + The model's variable atom counts do not change its parameter shapes. Copy + each new gradient into a stable buffer, replay the native Muon operations, + then apply the current scheduler learning rate outside the graph. The + optimizer state dict remains identical to torch.optim.Muon. CPU and mixed + device parameter sets use the native implementation directly. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._update_graphs = {} + + def load_state_dict(self, state_dict): + # Loading replaces momentum tensors; captured graphs own old addresses. + self._update_graphs.clear() + return super().load_state_dict(state_dict) + + @staticmethod + def _capture(grads, buffers, group): + static_grads = [grad.clone() for grad in grads] + backup = [buffer.clone() for buffer in buffers] + + def compute_updates(): + updates = [] + for grad, buf in zip(static_grads, buffers, strict=True): + buf.lerp_(grad, 1 - group["momentum"]) + update = grad.lerp(buf, group["momentum"]) if group["nesterov"] else buf + updates.append( + _zeropower_via_newtonschulz( + update, + group["ns_coefficients"], + group["ns_steps"], + group["eps"], + ) + ) + return updates + + stream = torch.cuda.Stream(device=grads[0].device) + stream.wait_stream(torch.cuda.current_stream()) + # Warm library handles and allocations before stream capture. Restore + # momentum afterwards so warm-up never counts as a training update. + with torch.cuda.stream(stream): + compute_updates() + compute_updates() + torch.cuda.current_stream().wait_stream(stream) + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph, stream=stream): + updates = compute_updates() + torch._foreach_copy_(buffers, backup) + return graph, static_grads, updates + + @torch.no_grad() + def step(self, closure=None): + devices = {p.device for group in self.param_groups for p in group["params"]} + if len(devices) != 1 or next(iter(devices)).type != "cuda": + return super().step(closure) + device = next(iter(devices)) + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + with torch.cuda.device(device): + for gi, group in enumerate(self.param_groups): + params, grads, buffers = [], [], [] + self._init_group(group, params, grads, buffers) + if not params: + continue + # Gradient participation, layout and recurrence settings may + # change, while LR and weight decay can change without capture. + key = ( + tuple(id(p) for p in params), + tuple(id(b) for b in buffers), + tuple((g.shape, g.stride(), g.dtype, g.device) for g in grads), + group["momentum"], + group["nesterov"], + tuple(group["ns_coefficients"]), + group["ns_steps"], + group["eps"], + ) + cached = self._update_graphs.get(gi) + if cached is None or cached[0] != key: + self._update_graphs.pop(gi, None) + cached = (key, *self._capture(grads, buffers, group)) + self._update_graphs[gi] = cached + _, graph, static_grads, updates = cached + torch._foreach_copy_(static_grads, grads) + graph.replay() + for param, update in zip(params, updates, strict=True): + param.mul_(1 - group["lr"] * group["weight_decay"]) + param.add_( + update, + alpha=-_adjust_lr( + group["lr"], group["adjust_lr_fn"], param.shape + ), + ) + return loss + + +def gradient_accumulation_plan( + batch_idx: int, steps: int, is_last_batch: bool +) -> tuple[bool, bool, int]: + """Return ``(zero_first, step_now, loss_divisor)`` for one microbatch.""" + if steps <= 0: + raise ValueError("gradient accumulation steps must be positive") + offset = batch_idx % steps + step_now = offset + 1 == steps or is_last_batch + divisor = offset + 1 if is_last_batch and offset + 1 < steps else steps + return offset == 0, step_now, divisor + + +class OptimizerMixin: + """Own all automatic and native Muon/AdamW optimization behavior.""" + + _AUXILIARY_MATRIX_PREFIXES = ( + "embedding_backbone.", + "heads.", + "insertion_head.", + "ins_edge_head.", + # The context encoder's input feature projections play the same role as + # ``embedding_backbone``. Its attention/MLP blocks remain on Muon. + "xattn_encoder.pos_proj.", + "xattn_encoder.token_proj.", + ) + _AUXILIARY_MATRIX_PATHS = ( + ".pair_bias.atom_pair_embedding.", + ".lora_A", + ".lora_B", + ) + + def _setup_optimization(self) -> None: + """Select Lightning's automatic or native multi-optimizer path.""" + implementation = self.optimizer_config.get("implementation", "torch") + if implementation not in {"torch", "external"}: + raise ValueError( + "optimizer implementation must be 'torch' or 'external', got " + f"{implementation!r}" + ) + # A Hydra target and the legacy combined implementation each expose a + # single optimizer, so Lightning can own their stepping natively. The + # documented torch Muon + AdamW split requires manual optimization. + self.automatic_optimization = ( + "_target_" in self.optimizer_config.optimizer + or implementation == "external" + ) + self.manual_gradient_accumulation_steps = 1 + + def _optimizer_update_step(self) -> int: + """Return completed model updates.""" + return self.global_step + + def _should_log_grad_norms(self) -> bool: + every = self.grad_norm_log_interval + return every > 0 and (self._optimizer_update_step() % every) == 0 + + @classmethod + def _use_muon(cls, name, parameter) -> bool: + """Route hidden 2D matrices to Muon and auxiliary tensors to AdamW.""" + # ``torch.compile`` wraps the model and prefixes every parameter name. + name = name.removeprefix("_orig_mod.") + is_auxiliary = name.startswith(cls._AUXILIARY_MATRIX_PREFIXES) or any( + path in name for path in cls._AUXILIARY_MATRIX_PATHS + ) + return parameter.ndim == 2 and not is_auxiliary + + @staticmethod + def _embedding_parameter_ids(model) -> set[int]: + """Find lookup tables by module type rather than extension-specific names.""" + return { + id(parameter) + for module in model.modules() + if isinstance(module, torch.nn.Embedding) + for parameter in module.parameters(recurse=False) + } + + def configure_gradient_clipping( + self, + optimizer, + gradient_clip_val=None, + gradient_clip_algorithm=None, + ): + """Apply module-level clipping settings for automatic optimizers.""" + clip_val = self.optimizer_config.get("gradient_clip_val", gradient_clip_val) + algorithm = self.optimizer_config.get( + "gradient_clip_algorithm", gradient_clip_algorithm + ) + self.clip_gradients( + optimizer, + gradient_clip_val=clip_val, + gradient_clip_algorithm=algorithm, + ) + + def configure_optimizers(self): + """Build one configured optimizer or native Muon plus auxiliary AdamW.""" + opt_cfg = self.optimizer_config.optimizer + params = [ + parameter + for parameter in self.model.parameters() + if parameter.requires_grad + ] + learnable_loss_weights = ( + self.loss_weight_wrapper.use_learnable + and self.loss_weight_wrapper.learnable_wrapper is not None + ) + if learnable_loss_weights: + params.extend(self.loss_weight_wrapper.learnable_wrapper.parameters()) + + if "_target_" in opt_cfg: + optimizer = hydra.utils.instantiate(opt_cfg, params=params) + self._optimizer_group_names = ["all"] + scheduler = self._make_scheduler(optimizer) + return { + "optimizer": optimizer, + "lr_scheduler": { + "scheduler": scheduler, + "interval": self.optimizer_config.interval, + "monitor": self.optimizer_config.monitor, + }, + "monitor": self.optimizer_config.monitor, + } + + muon_params, adam_params = [], [] + embedding_parameter_ids = self._embedding_parameter_ids(self.model) + for name, parameter in self.model.named_parameters(): + if not parameter.requires_grad: + continue + is_embedding = id(parameter) in embedding_parameter_ids + use_muon = self._use_muon(name, parameter) and not is_embedding + target = muon_params if use_muon else adam_params + target.append(parameter) + if learnable_loss_weights: + adam_params.extend(self.loss_weight_wrapper.learnable_wrapper.parameters()) + + implementation = self.optimizer_config.get("implementation", "torch") + aux_cfg = self.optimizer_config.aux_optimizer + if implementation == "external": + named_groups = [] + if adam_params: + named_groups.append( + ( + "adam_aux", + { + "params": adam_params, + "lr": aux_cfg.lr, + "betas": tuple(aux_cfg.betas), + "eps": aux_cfg.get("eps", 1e-10), + "weight_decay": aux_cfg.weight_decay, + "use_muon": False, + }, + ) + ) + if muon_params: + named_groups.append( + ( + "muon", + { + "params": muon_params, + "lr": opt_cfg.lr, + "momentum": opt_cfg.momentum, + "weight_decay": opt_cfg.weight_decay, + "use_muon": True, + }, + ) + ) + if not named_groups: + raise RuntimeError("The model has no trainable parameters") + optimizer_cls = ( + MuonWithAuxAdam + if dist.is_available() and dist.is_initialized() + else SingleDeviceMuonWithAuxAdam + ) + optimizer = optimizer_cls([group for _, group in named_groups]) + self._optimizer_group_names = [name for name, _ in named_groups] + scheduler = self._make_scheduler(optimizer) + return { + "optimizer": optimizer, + "lr_scheduler": { + "scheduler": scheduler, + "interval": self.optimizer_config.interval, + "monitor": self.optimizer_config.monitor, + }, + "monitor": self.optimizer_config.monitor, + } + + optimizers = [] + optimizer_names = [] + if muon_params: + execution = opt_cfg.get("execution", "native") + if execution not in {"native", "cuda_graph"}: + raise ValueError("Muon execution must be native or cuda_graph") + optimizer_cls = torch.optim.Muon if execution == "native" else CudaGraphMuon + optimizers.append( + optimizer_cls( + muon_params, + lr=opt_cfg.lr, + momentum=opt_cfg.momentum, + weight_decay=opt_cfg.weight_decay, + nesterov=opt_cfg.get("nesterov", True), + ns_steps=opt_cfg.get("ns_steps", 5), + eps=opt_cfg.get("eps", 1e-7), + adjust_lr_fn=opt_cfg.get("adjust_lr_fn", "original"), + ) + ) + optimizer_names.append("muon") + + if adam_params: + optimizers.append( + torch.optim.AdamW( + adam_params, + lr=aux_cfg.lr, + betas=tuple(aux_cfg.betas), + eps=aux_cfg.get("eps", 1e-10), + weight_decay=aux_cfg.weight_decay, + ) + ) + optimizer_names.append("adam_aux") + if not optimizers: + raise RuntimeError("The model has no trainable parameters") + + self._optimizer_group_names_by_id = { + id(optimizer): [name] + for optimizer, name in zip(optimizers, optimizer_names, strict=True) + } + return optimizers, [self._make_scheduler(optimizer) for optimizer in optimizers] + + def _make_scheduler(self, optimizer): + scheduler_cfg = dict( + OmegaConf.to_container(self.optimizer_config.scheduler, resolve=True) + ) + scheduler_cfg["optimizer"] = optimizer + return hydra.utils.instantiate(scheduler_cfg) + + def _manual_training_step(self, batch, batch_idx): + """Accumulate gradients and step all native optimizers together.""" + optimizers = self.optimizers() + if not isinstance(optimizers, (list, tuple)): + optimizers = (optimizers,) + zero_first, step_now, loss_divisor = gradient_accumulation_plan( + batch_idx, + self.manual_gradient_accumulation_steps, + self.trainer.is_last_batch, + ) + if zero_first: + for optimizer in optimizers: + optimizer.zero_grad(set_to_none=True) + + loss = self.shared_step(batch, batch_idx) + if step_now: + self._maybe_log_per_loss_grad_norms() + self.log("loss/train", loss.detach(), prog_bar=True, logger=True) + + block_sync = getattr(self.trainer.strategy, "block_backward_sync", nullcontext) + sync_context = nullcontext() if step_now else block_sync() + with sync_context: + self.manual_backward(loss / loss_divisor) + if not step_now: + return loss + + # The two native optimizers form one model update. Step the first + # through Lightning so precision hooks and the manual-loop progress + # tracker run once, then step the remaining native optimizer(s) through + # the same strategy/precision path without incrementing global_step. + optimizers[0].step() + for optimizer in optimizers[1:]: + raw_optimizer = getattr(optimizer, "optimizer", None) + if raw_optimizer is None: # lightweight test harness / direct call + optimizer.step() + else: + self.trainer.strategy.optimizer_step( + raw_optimizer, + lambda: None, + ) + schedulers = self.lr_schedulers() + if not isinstance(schedulers, (list, tuple)): + schedulers = (schedulers,) + for scheduler in schedulers: + scheduler.step() + self._update_ema() + return loss + + def on_before_optimizer_step(self, optimizer): + """Log gradient norms and clip the shared manual gradient once.""" + if self.automatic_optimization: + self._log_optimizer_grad_norms(optimizer) + return + + # Both native optimizers form one logical update. Log both branches and + # clip the full gradient vector once, immediately before the first step. + if optimizer is not self.trainer.optimizers[0]: + return + self._log_optimizer_grad_norms(optimizer) + if self._should_log_grad_norms(): + for other_optimizer in self.trainer.optimizers[1:]: + self._log_param_group_grad_norms(other_optimizer) + self._clip_all_gradients() + + def _log_optimizer_grad_norms(self, optimizer) -> None: + if not self._should_log_grad_norms(): + return + # Do not log per-parameter norms: their data-dependent key sets can + # differ across DDP ranks and deadlock metric synchronization. + norms = grad_norm(self, norm_type=2) + total = norms.get("grad_2.0_norm_total") + if total is not None: + self.log("grad_2.0_norm_total", total, on_step=True, on_epoch=False) + self._log_param_group_grad_norms(optimizer) + + def _log_param_group_grad_norms(self, optimizer): + """Log one stable, unclipped L2 gradient norm per parameter group.""" + names_by_id = getattr(self, "_optimizer_group_names_by_id", {}) + group_names = names_by_id.get( + id(optimizer), getattr(self, "_optimizer_group_names", None) + ) + entries: dict[str, torch.Tensor] = {} + for index, group in enumerate(optimizer.param_groups): + name = ( + group_names[index] + if group_names is not None and index < len(group_names) + else f"group{index}" + ) + sq_sum: torch.Tensor | None = None + for parameter in group["params"]: + if parameter.grad is None: + continue + grad_sq = parameter.grad.detach().norm(2) ** 2 + sq_sum = grad_sq if sq_sum is None else sq_sum + grad_sq + entries[f"grad_2.0_norm_pg/{name}"] = ( + torch.zeros((), device=self.device) if sq_sum is None else sq_sum.sqrt() + ) + if entries: + self.log_dict(entries, on_step=True, on_epoch=False) + + def _clip_all_gradients(self) -> None: + clip_val = float(self.optimizer_config.get("gradient_clip_val", 0.0)) + if clip_val <= 0: + return + parameters = ( + parameter for parameter in self.parameters() if parameter.grad is not None + ) + algorithm = self.optimizer_config.get("gradient_clip_algorithm", "norm") + if algorithm == "value": + torch.nn.utils.clip_grad_value_(parameters, clip_val) + else: + torch.nn.utils.clip_grad_norm_(parameters, clip_val) + + def optimizer_step( + self, + epoch, + batch_idx, + optimizer, + optimizer_closure, + on_tpu=False, + using_native_amp=False, + using_lbfgs=False, + ): + """Run an automatic optimizer step and update EMA exactly once.""" + optimizer.step(closure=optimizer_closure) + self._update_ema() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_sampling.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_sampling.py new file mode 100644 index 0000000000000000000000000000000000000000..a26ac38ccfc02b66e7d0abd4bd1aeefd7768f360 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_sampling.py @@ -0,0 +1,399 @@ +"""Inference: prediction and trajectory sampling. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.atomic_data import ( + COFRAMED_CONTEXT_KEYS, + batch_from_flat, + batch_translate, +) +from morph.dataset.core.representation import neutral_charge_index +from morph.flow_matching.integration import IntegrationPredictions +from morph.flow_matching.sampling_step import SamplingStepTrace +from morph.utils import rdkit_utils as morphRD + +if TYPE_CHECKING: + from collections.abc import Callable + + +def _sample_logits(logits: torch.Tensor) -> torch.Tensor: + """Sample categorical logits in fp32 without a distribution wrapper.""" + return torch.multinomial(torch.softmax(logits.float(), dim=-1), 1).squeeze(1) + + +@dataclass +class _OutputFrame: + """Restore physical units and, for fixed scaffolds, the input coordinate frame.""" + + coordinate_std: float + translation: torch.Tensor | None + + @classmethod + def from_batch(cls, mol, coordinate_std, *, restore_input_frame): + mean = mol.remove_com() + return cls( + coordinate_std, mean.detach().clone() if restore_input_frame else None + ) + + def recenter(self, mol): + mean = mol.remove_com() + if self.translation is not None: + self.translation.add_(mean.detach()) + + def restore(self, mol): + if self.translation is not None: + batch_translate(mol, self.translation) + mol.x.mul_(self.coordinate_std) + for key in COFRAMED_CONTEXT_KEYS: + context = getattr(mol, key, None) + if context is not None: + context.positions.mul_(self.coordinate_std) + return mol + + def snapshot(self, mol): + return self.restore(mol.clone()).to_inplace("cpu", non_blocking=False) + + +def _sampling_interval(step_sizes, t_start): + """Select a schedule suffix, snapping its start to the next grid point.""" + if t_start <= 0.0: + return 0.0, step_sizes + cumulative = torch.cumsum( + torch.tensor([0.0] + [float(s) for s in step_sizes]), dim=0 + ) + start = int((cumulative >= float(t_start)).nonzero()[0].item()) + return float(cumulative[start].item()), step_sizes[start:] + + +class SamplingMixin: + def _apply_inference_charge_mask(self, mol_t, c_pred): + """Optionally preserve task-constrained charges on existing atoms.""" + return c_pred + + def _apply_inference_edit_masks( + self, + mol_t, + x1_pred: torch.Tensor, + do_sub_a_probs: torch.Tensor, + do_sub_e_probs: torch.Tensor, + do_del_probs: torch.Tensor, + num_ins_pred: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Optionally pin clean-state predictions before integration. + + Conditional subclasses override this to preserve their scaffold. + """ + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred + + def predict_step(self, batch, batch_idx): + return_traj = bool(getattr(self, "predict_return_traj", False)) + + gen_mols = self.sample( + batch, + batch_idx, + return_traj=return_traj, + ) + if not return_traj: + # Transfer once per batch, then retain the established list-of- + # frames interface with one final frame per attempted molecule. + final_mols = gen_mols.to_inplace("cpu", non_blocking=False).to_data_list() + gen_mols = [[mol] for mol in final_mols] + + # In non-topology modes, runtime edges labeled are not chemical + # bonds. Skip the validity pipeline and return the raw samples. + if not self.representation.requires_topology: + return { + "all_mols": list(gen_mols), + "is_valid": [False] * len(gen_mols), + "valid_mols": [], + "invalid_mols": list(gen_mols), + "invalid_mols_rdkit": [], + } + + # do quick validity check of the generated molecules + # take the last state of the trajectory and check validity + last_mols_rdkit = [ + i[-1].to_rdkit_mol( + self.vocab.atom_tokens, self.vocab.edge_tokens, self.vocab.charge_tokens + ) + for i in gen_mols + ] + mol_is_valid = [] + for mol in last_mols_rdkit: + if mol is None: + mol_is_valid.append(False) + continue + try: + mol_is_valid.append( + morphRD.mol_is_valid(mol, allow_charged=self.allow_charged) + ) + except Exception as e: + print(f"Error checking validity of molecule: {e}") + mol_is_valid.append(False) + + valid_mols = [ + i for i, valid in zip(gen_mols, mol_is_valid, strict=True) if valid + ] + # Keep trajectories for invalid molecules so failures can be inspected. + invalid_mols = [ + i for i, valid in zip(gen_mols, mol_is_valid, strict=True) if not valid + ] + invalid_mols_rdkit = [ + i + for i, valid in zip(last_mols_rdkit, mol_is_valid, strict=True) + if not valid + ] + + # ``all_mols`` / ``is_valid`` keep the dataloader batch order. The + # valid/invalid split re-orders molecules, so consumers that join + # per-molecule outputs must read the batch-ordered keys. + return { + "all_mols": list(gen_mols), + "is_valid": mol_is_valid, + "valid_mols": valid_mols, + "invalid_mols": invalid_mols, + "invalid_mols_rdkit": invalid_mols_rdkit, + } + + def _integrate_step(self, mol_t, predictions: IntegrationPredictions, **kwargs): + """Run one integration step on native Morph graph batches.""" + return self.integrator.integrate_step_gnn( + mol_t=mol_t, predictions=predictions, **kwargs + ) + + def _decode_predictions(self, mol_t, preds, *, requires_topology, force_charge_idx): + """Sample clean endpoint tokens, then apply representation and task masks.""" + # Extract predictions + x1_pred = preds["pos_head"] # (N_total, D) + + a_pred = _sample_logits(preds["atom_type_head"]) + c_pred = _sample_logits(preds["charge_head"]) + + # NOTE: predictions are for full adj matrix. + # NOTE: Will take triu and resymmetrize in integration step + e_pred = _sample_logits(preds["edge_type_head"]) + + insertion_pred = preds["insertion_head"] + + # Extract scalar decision logits; integration consumes probabilities. + do_sub_a_probs = torch.sigmoid(preds["do_sub_a_head"].view(-1)) + do_sub_e_probs = torch.sigmoid(preds["do_sub_e_head"].view(-1)) + do_del_probs = torch.sigmoid(preds["do_del_head"].view(-1)) + + # Channels absent from the representation retain their inert tokens. + if not requires_topology: + e_pred.fill_(0) # all + do_sub_e_probs.zero_() # never substitute an edge + if force_charge_idx is not None: + c_pred.fill_(force_charge_idx) + else: + c_pred = self._apply_inference_charge_mask(mol_t, c_pred) + + # Extract insertion rate prediction (number of insertions per node) + num_ins_pred = preds["ins_rate_head"].view(-1) + + # Subclass hook: scaffold fine-tuning hard-zeros del / sub-a / sub-e + # (scaffold-scaffold) probs at scaffold positions and optionally pins + # their clean coordinate target. This must run before mol_1_pred is built. + x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred = ( + self._apply_inference_edit_masks( + mol_t, + x1_pred, + do_sub_a_probs, + do_sub_e_probs, + do_del_probs, + num_ins_pred, + ) + ) + + # Clean-target prediction over mol_t's current topology. + mol_1_pred = batch_from_flat( + positions=x1_pred, + atom_types=a_pred, + charge_types=c_pred, + edge_types=e_pred, + edge_index=mol_t.edge_index, + batch_idx=mol_t.batch, + num_graphs=mol_t.num_graphs, + _already_sorted=True, + _node_counts=mol_t.num_nodes_per_graph, + _edge_counts=mol_t.num_edges_per_graph, + ) + + h_latent = preds["h_latent"] + + return IntegrationPredictions( + mol_1_pred=mol_1_pred, + do_sub_a_probs=do_sub_a_probs, + do_sub_e_probs=do_sub_e_probs, + do_del_probs=do_del_probs, + num_ins_pred=num_ins_pred, + insertion_preds=insertion_pred, + h_latent=h_latent, + ) + + def sample( + self, + batch, + batch_idx, + return_traj: bool = False, + t_start: float = 0.0, + sampling_step_callback: Callable[[SamplingStepTrace], None] | None = None, + position_update: Callable | None = None, + ): + """Inference step for flow matching. + + Args: + batch: Tuple ``(mol_t, mol_1)`` from the dataloader. + batch_idx: Batch index. + return_traj: Return per-molecule trajectories instead of the final state. + t_start: If > 0, integrate only ``[t_start, 1]`` (snapped to a + schedule time point) starting from ``batch[0]`` as the state at + ``t_start``. Used by the renoise self-correction ablation. + sampling_step_callback: Optional callback receiving a + ``SamplingStepTrace`` after each integration step. It is only + called when supplied, so normal inference has no trace overhead. + position_update: Optional coordinate update forwarded to the integrator. + + Returns: + If ``return_traj`` is False: a ``ChemBatch`` with the final state. + If ``return_traj`` is True: a per-molecule list of trajectory frames. + + Sampling stays centered internally. When kept positions are frozen, the + accumulated translations are restored on output so kept coordinates and + attached geometric context remain in the input frame. + """ + model = self._get_model() + compiled_model = getattr( + model, "_compiled_call_impl", None + ) is not None or hasattr(model, "_orig_mod") + if self.vocab.atom_tokens is None: + raise ValueError( + "vocab.atom_tokens must be supplied when constructing " + "the sampling module." + ) + + # Get batch size from the batch (assuming it's similar to training) + mol_t, _mol_1 = batch + + batch_size = mol_t.batch_size + + output_frame = _OutputFrame.from_batch( + mol_t, + self.distributions.coordinate_std, + restore_input_frame=bool( + getattr(self, "_freeze_keep_positions", False) + and getattr(mol_t, "keep_mask", None) is not None + ), + ) + + t0, step_sizes = _sampling_interval(self.integrator.get_time_steps(), t_start) + t = torch.full((batch_size,), t0, device=self.device) + + # Trajectory storage (only used when return_traj=True). + # Frames live on CPU to avoid linear GPU growth over the integration loop. + mol_traj = [output_frame.snapshot(mol_t)] if return_traj else None + + # Per-trajectory conditioning that is invariant across integration steps + # (e.g. scaffold tokens encoded from the fixed start molecule) is + # computed once here and threaded into every step, + # instead of the model re-encoding it each step. Models without extra + # conditioning don't define the hook -> empty dict -> unchanged behaviour. + cond_kwargs: dict = {} + _precompute = getattr(model, "precompute_conditioning", None) + if _precompute is not None: + cond_kwargs = _precompute(mol_t) + + requires_topology = self.representation.requires_topology + requires_charges = self.representation.requires_charges + ins_edge_head = ( + getattr(model, "ins_edge_head", None) if requires_topology else None + ) + force_charge_idx = ( + None if requires_charges else neutral_charge_index(self.vocab) + ) + + integration_extensions = {} + if sampling_step_callback is not None: + integration_extensions["return_step_trace"] = True + if position_update is not None: + integration_extensions["position_update"] = position_update + + # Integration loop: integrate from t=0 to t=1 + for step_index, step_size in enumerate(step_sizes): + is_final = step_index == len(step_sizes) - 1 + state_before = mol_t + + # Resolve the dynamic graph size before entering Dynamo, as on the + # training path. Reading its CUDA scalar inside the model breaks + # compilation after topology changes invalidate the cached size. + if compiled_model: + cond_kwargs["max_seqlen"] = mol_t.max_seqlen + preds = model(mol_t, t.view(-1, 1), **cond_kwargs) + + predictions = self._decode_predictions( + mol_t, + preds, + requires_topology=requires_topology, + force_charge_idx=force_charge_idx, + ) + + # Integrate one step (edge prediction happens inside if head is provided) + step_result = self._integrate_step( + mol_t=mol_t, + predictions=predictions, + t=t, + dt=step_size, + is_final=is_final, + ins_edge_head=ins_edge_head, + force_charge_idx=force_charge_idx, + **integration_extensions, + ) + if sampling_step_callback is None: + mol_t = step_result + integration_trace = None + else: + mol_t, integration_trace = step_result + + # Release decoded predictions before the next forward pass. + del predictions + output_frame.recenter(mol_t) + + if sampling_step_callback is not None: + sampling_step_callback( + SamplingStepTrace( + mol_t=state_before, + mol_t_next=mol_t, + t=t, + dt=step_size, + preds=preds, + integration=integration_trace, + is_final=is_final, + ) + ) + del preds, state_before, integration_trace, step_result + + # Update time forward + # Number of graphs stays constant (batch_size) + t = t + step_size + + # Save state to trajectory on CPU; non-traj path only keeps the final frame. + if return_traj: + mol_traj.append(output_frame.snapshot(mol_t)) + + if return_traj: + # rectify the trajectory such that we get a traj for each molecule + traj_lists = [mol_traj_i.to_data_list() for mol_traj_i in mol_traj] + + return [list(frames) for frames in zip(*traj_lists, strict=True)] + + # The final integration state is local and can be scaled in place. + return output_frame.restore(mol_t) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_setup.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..93f6248f6cc35915a8b97146198784d2386a4cb2 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_setup.py @@ -0,0 +1,146 @@ +# Private compiler configuration is loaded only when compilation is enabled. +# ruff: noqa: PLC0415 + +"""Model setup: compile, EMA, forward. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +import os + +import torch + +from morph.model.layers.attention import fix_efficient_attention_bias_metadata +from morph.utils.checkpoints import canonicalize_state_dict, state_dict_for_module + + +class SetupMixin: + def compile(self): + """Compile current and EMA models in place, unless MORPH_COMPILE=0. + + Both entry points use this setup. Compilation is lazy: training and + inference acquire the appropriate graphs when first executed. In-place + compilation keeps module identity, parameter references and state keys. + """ + if self.is_compiled: + return + if os.environ.get("MORPH_COMPILE", "1") == "0": + print("Model compilation disabled (MORPH_COMPILE=0).") + return + print("Compiling current and EMA models (lazy; MORPH_COMPILE=0 disables).") + fix_efficient_attention_bias_metadata() + # PyTorch 2.9's DDP graph partitioner can lose FakeTensor metadata on + # recompilation after validation. Keep this workaround opt-in. + import torch._dynamo.config as dynamo_config + + if os.environ.get("MORPH_COMPILE_DDP_OPTIMIZER") == "0": + dynamo_config.optimize_ddp = False + print("DDP compiler graph splitting disabled.") + if os.environ.get("MORPH_COMPILE_FALLBACK") == "1": + dynamo_config.suppress_errors = True + print("Compiler errors will warn and fall back to eager execution.") + # Per-loss grad-norm logging calls torch.autograd.grad(..., retain_graph=True) + # before the main backward. torch.compile's donated-buffer optimization frees + # activations after the first backward and rejects any subsequent one, so + # disable it when grad-norm tracking is active. + if self.log_grad_norms_every_n_steps > 0: + import torch._functorch.config as _functorch_config + + _functorch_config.donated_buffer = False + for model in (self.model, self.model_ema): + if model is not None: + model.compile(dynamic=True) + self.is_compiled = True + + def on_save_checkpoint(self, checkpoint): + """Save portable parameter names independent of ``torch.compile``.""" + state_dict = checkpoint.get("state_dict") + if state_dict is not None: + checkpoint["state_dict"] = canonicalize_state_dict(state_dict) + + def on_load_checkpoint(self, checkpoint): + """Adapt eager or compiled checkpoint keys to the current module.""" + state_dict = checkpoint.get("state_dict") + if state_dict is not None: + checkpoint["state_dict"] = state_dict_for_module(state_dict, self) + # Lightning logs a microbatch counter. Rebase it when a resumed manual + # run changes microbatch size/accumulation, preserving the update axis. + if not getattr(self, "automatic_optimization", True): + loop_state = ( + checkpoint.get("loops", {}) + .get("fit_loop", {}) + .get("epoch_loop.state_dict") + ) + if loop_state is not None and "global_step" in checkpoint: + loop_state["_batches_that_stepped"] = ( + checkpoint["global_step"] * self.manual_gradient_accumulation_steps + ) + + def _get_model(self): + """Return model or model_ema for inference based on use_ema_for_eval.""" + return ( + self.model_ema + if self.use_ema_for_eval and self.model_ema is not None + else self.model + ) + + def _update_ema(self): + """Update EMA parameters: ema = decay * ema + (1 - decay) * model.""" + # In automatic optimization this hook runs just before Lightning marks + # the optimizer step complete; manual optimization has already marked + # both optimizer branches complete. Convert both cases to the same + # one-based count of logical model updates. + update_step = self._optimizer_update_step() + if self.automatic_optimization: + update_step += 1 + + ema_decay = 0.0 + if update_step > self.ema_warmup_steps: + ema_decay = float(self.ema_decay) + if ema_decay and self.ema_decay_scheduler is not None: + ema_decay = self.ema_decay_scheduler.get_decay( + current_epoch=self.current_epoch, + ) + with torch.no_grad(): + ema_tensors = getattr(self, "_ema_tensors", None) + if ema_tensors is None: + ema_params = tuple(self.model_ema.parameters()) + model_params = tuple(self.model.parameters()) + ema_buffers = tuple(self.model_ema.buffers()) + model_buffers = tuple(self.model.buffers()) + if len(ema_params) != len(model_params): + raise RuntimeError("EMA and training model parameter sets differ") + if len(ema_buffers) != len(model_buffers): + raise RuntimeError("EMA and training model buffer sets differ") + ema_tensors = ( + ema_params, + model_params, + ema_buffers, + model_buffers, + ) + self._ema_tensors = ema_tensors + ema_params, model_params, ema_buffers, model_buffers = ema_tensors + # One grouped multi-tensor recurrence instead of two launches per + # parameter. A representative CPU transformer mix was ~22% faster; + # CUDA benefits more from eliminating the Python/kernel-launch loop. + if ema_decay == 0.0: + torch._foreach_copy_(ema_params, model_params) + else: + torch._foreach_lerp_(ema_params, model_params, 1.0 - ema_decay) + # Buffers are state rather than trainable weights; keep them exact, + # including integer counters that cannot be averaged. + if ema_buffers: + torch._foreach_copy_(ema_buffers, model_buffers) + + self.log( + "ema/decay", + ema_decay, + on_step=True, + on_epoch=False, + prog_bar=False, + logger=True, + ) + + def forward(self, *args, **kwargs): + """Forward through the trainable model.""" + return self.model(*args, **kwargs) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_training.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_training.py new file mode 100644 index 0000000000000000000000000000000000000000..4ab3d22a2b6ad81f37999bdabdf52e34574b680d --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_training.py @@ -0,0 +1,73 @@ +"""Lightning training orchestration over the reusable molecular objective.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from morph.model.losses import FlowLossMixin + +if TYPE_CHECKING: + import torch + + +class TrainingMixin(FlowLossMixin): + """Run the standalone Morph forward, loss, and training diagnostics.""" + + def shared_step(self, batch, batch_idx): + """Predict once, compute the molecular objective, and log its terms.""" + if self.vocab.atom_tokens is None: + raise ValueError( + "vocab.atom_tokens must be supplied when constructing " + "the training module." + ) + + mols_t, _, _, t = batch + # Resolve the Python graph-size bound outside a compiled forward. + preds = self.model(mols_t, t.view(-1, 1), max_seqlen=mols_t.max_seqlen) + loss = self.loss_from_predictions(preds, batch) + self.log_dict(self.loss_accumulator.log_dict(), prog_bar=False, logger=True) + return loss + + def _automatic_training_step(self, batch, batch_idx): + """Compute and log a loss for Lightning's automatic optimization.""" + loss = self.shared_step(batch, batch_idx) + self._maybe_log_per_loss_grad_norms() + self.log("loss/train", loss.detach(), prog_bar=True, logger=True) + return loss + + def _maybe_log_per_loss_grad_norms(self) -> None: + """Log per-loss-term gradient norms at the configured cadence. + + Runs *before* Lightning calls ``loss.backward()``, leveraging + ``retain_graph=True`` inside ``LossAccumulator.compute_grad_norms`` so + the autograd graph survives for the actual backward pass. The + ``.grad`` attributes on parameters are not touched, so this is safe to + interleave with DDP's reducer (which only fires on + ``loss.backward()``). + """ + every = self.log_grad_norms_every_n_steps + if every <= 0 or (self._optimizer_update_step() % every) != 0: + return + + norms = self.loss_accumulator.compute_grad_norms( + self.model.parameters(), + granularity=self.grad_norms_granularity, + use_weighted=False, + apply_component_weights=False, + ) + if not norms: + return + + component_keys = {k for keys in self.LOSS_GROUPS.values() for k in keys} + entries: dict[str, torch.Tensor] = {} + for key, val in norms.items(): + prefix = "grad_norm" if key in component_keys else "grad_norm_group" + entries[f"{prefix}/{key}"] = val.detach() + + self.log_dict( + entries, + on_step=True, + on_epoch=False, + prog_bar=False, + logger=True, + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_validation.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..d87290f2081c6cac6f08f41cae69fbad20c543cf --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/_validation.py @@ -0,0 +1,283 @@ +"""Validation: per-batch and epoch-end metrics. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +import os +from contextlib import ExitStack + +import torch +import torch.distributed as dist + +from morph.utils.metrics import ( + build_marginal_plots, + calc_atom_stabilities, + calc_metrics_, +) +from morph.utils.metrics.tensor import compute_batch_geometry + + +class ValidationMixin: + def _validation_context(self, batch): + """Optionally snapshot task-specific inputs before sampling.""" + return None + + def _update_task_metrics(self, context, mols, rdkit_mols): + """Consume generated graphs and decoded molecules in conditional tasks.""" + + def _compute_chemistry_metrics(self, rdkit_mols): + """Evaluate the gathered population, optionally with task context.""" + return calc_metrics_(rdkit_mols, self.metrics) + + def on_validation_epoch_start(self): + # ``seed_everything`` deliberately gives every DDP process the same base + # seed. That is correct for model initialization, but an unconditional + # validation dataset otherwise draws the same priors on every rank and + # turns 4,096 requested samples into four copies of 1,024. Salt the + # validation RNG by rank; keeping the seed fixed across epochs also makes + # changes in the validation curve attributable to the model, not priors. + self._training_rng_state = torch.random.get_rng_state() + self._training_cuda_rng_states = ( + torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None + ) + base_seed = int(os.environ.get("PL_GLOBAL_SEED", "0")) + torch.manual_seed(base_seed + self.global_rank) + + self.model_ema.eval() + # Tensor-style collections accumulate raw counts/histograms across the + # epoch so the reported metric is computed from the full epoch's atoms + # (not a per-batch rate averaged with the wrong weighting). + if self.distribution_metrics is not None: + self.distribution_metrics.reset() + if self.batch_metrics is not None: + self.batch_metrics.reset() + if self.stability_metrics is not None: + self.stability_metrics.reset() + if self.metrics is not None: + self.metrics.reset() + self._validation_rdkit_mols = [] + + def validation_step(self, batch, batch_idx): + context = self._validation_context(batch) + batched_mols = self.sample(batch, batch_idx, return_traj=False) + + # Tensor / distribution metrics consume the batch directly and work in + # every representation, so they always run — no early return. Only the + # RDKit-mol chemistry metrics below require real topology. + if self.batch_metrics is not None and len(self.batch_metrics) > 0: + self.batch_metrics.update(compute_batch_geometry(batched_mols)) + if self.distribution_metrics is not None and len(self.distribution_metrics) > 0: + self.distribution_metrics.update(batched_mols) + + if not self.representation.requires_topology: + del batched_mols + return + + # RDKit is CPU-only. Transfer each batch tensor once before splitting; + # converting GPU graph views one molecule at a time would synchronize + # every coordinate/type/edge ``.cpu()`` call separately. + mols = batched_mols.to_inplace("cpu", non_blocking=False).to_data_list() + del batched_mols + + # List of RDKit molecules or None + rdkit_mols = [] + for mol in mols: + try: + rdkit_mols.append( + mol.to_rdkit_mol( + self.vocab.atom_tokens, + self.vocab.edge_tokens, + self.vocab.charge_tokens, + ) + ) + except Exception as e: + print(f"Error converting molecule to RDKit: {e}") + rdkit_mols.append(None) + + self._update_task_metrics(context, mols, rdkit_mols) + self._validation_rdkit_mols.extend(rdkit_mols) + + # RDKit per-atom stability — fed precomputed bools (not RDKit mols). + # Accumulate only here; compute/log/reset happens in + # on_validation_epoch_end so the rate is weighted by atom count across + # the full epoch, not molecule count per batch. + if self.stability_metrics is not None and len(self.stability_metrics) > 0: + stabilities = [] + for graph in mols: + try: + mol = graph.to_rdkit_mol( + self.vocab.atom_tokens, + self.vocab.edge_tokens, + self.vocab.charge_tokens, + sanitize=False, + ) + stabilities.append(calc_atom_stabilities(mol)) + except Exception as exc: + # The generated atom count is known even if decoding fails; + # do not drop these atoms or molecules from the denominator. + print(f"Raw graph stability check failed: {exc}") + stabilities.append([False] * graph.num_nodes) + if stabilities: + self.stability_metrics.update(stabilities) + del mols + + def on_validation_epoch_end(self): + # Python/list-backed chemistry metrics cannot use torchmetrics' DDP + # state reduction. Gather the complete molecule population, compute it + # once on rank zero, then broadcast the small scalar result dictionary + # so every rank exposes identical callback metrics. + if self.representation.requires_topology: + local_mols = self._validation_rdkit_mols + if dist.is_available() and dist.is_initialized(): + gathered = ( + [None] * dist.get_world_size() if dist.get_rank() == 0 else None + ) + dist.gather_object(local_mols, gathered, dst=0) + rdkit_mols = ( + [mol for rank_mols in gathered for mol in rank_mols] + if gathered is not None + else [] + ) + else: + rdkit_mols = local_mols + + eval_metrics = {} + if self.trainer.is_global_zero: + try: + eval_metrics = self._compute_chemistry_metrics(rdkit_mols) + except Exception as e: + print(f"Error calculating chemistry metrics: {e}") + if dist.is_available() and dist.is_initialized(): + payload = [eval_metrics] + dist.broadcast_object_list(payload, src=0) + eval_metrics = payload[0] + if eval_metrics: + self.log_dict( + {f"val/{key}": value for key, value in eval_metrics.items()}, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + self._validation_rdkit_mols = [] + + # RDKit per-atom stability — accumulated across the full epoch in + # validation_step; compute the atom-weighted rate once here. + if self.stability_metrics is not None and len(self.stability_metrics) > 0: + try: + stab_results = self.stability_metrics.compute() + self.log_dict( + { + f"val/{k}": (v.detach() if isinstance(v, torch.Tensor) else v) + for k, v in stab_results.items() + }, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + except Exception as e: + print(f"Error in stability-metric epoch-end logging: {e}") + finally: + self.stability_metrics.reset() + + # Tensor-based metrics run in every representation; logged here so the + # values land in the same wandb step as everything else. + if self.batch_metrics is not None and len(self.batch_metrics) > 0: + try: + # Sync once for both scalar computation and histogram plots. + # Every rank must enter each collective context; only rank zero + # reads the synchronized state to render figures. + with ExitStack() as stack: + for m in self.batch_metrics.values(): + stack.enter_context(m.sync_context()) + + batch_results = self.batch_metrics.compute() + self.log_dict( + { + f"val/batch/{key}": ( + v.detach() if isinstance(v, torch.Tensor) else v + ) + for key, v in batch_results.items() + }, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + + if self.trainer.is_global_zero and hasattr( + self.logger, "log_image" + ): + from morph.utils.metrics.plotting import ( # noqa: PLC0415 + build_batch_marginal_plots, + ) + + figs = build_batch_marginal_plots( + self.batch_metrics, + atom_tokens=list(self.vocab.atom_tokens), + ) + for name, fig in figs.items(): + try: + self.logger.log_image(key=name, images=[fig]) + except Exception as e: + print(f"Error logging {name}: {e}") + if figs: + import matplotlib.pyplot as plt # noqa: PLC0415 + + for fig in figs.values(): + plt.close(fig) + except Exception as e: + print(f"Error in batch-metric epoch-end logging: {e}") + finally: + self.batch_metrics.reset() + + # Distribution KLs are tensor-native and accumulate in every + # representation; edge/charge KLs are only registered when meaningful + # (see init_metrics), so an empty/absent collection just no-ops. + if self.distribution_metrics is None or len(self.distribution_metrics) == 0: + self._restore_training_rng() + return + + try: + # Sync once for both pooled KLs and their marginal plots. + with ExitStack() as stack: + for m in self.distribution_metrics.values(): + stack.enter_context(m.sync_context()) + + dist_results = self.distribution_metrics.compute() + self.log_dict( + { + f"val/{key}": (v.detach() if isinstance(v, torch.Tensor) else v) + for key, v in dist_results.items() + }, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + + if self.trainer.is_global_zero and hasattr(self.logger, "log_image"): + figures = build_marginal_plots(self.distribution_metrics) + for name, fig in figures.items(): + self.logger.log_image(key=f"val/marginals/{name}", images=[fig]) + if figures: + import matplotlib.pyplot as plt # noqa: PLC0415 + + for fig in figures.values(): + plt.close(fig) + except Exception as e: + print(f"Error in validation-epoch-end distribution logging: {e}") + finally: + self.distribution_metrics.reset() + self._restore_training_rng() + + def _restore_training_rng(self): + """Keep deterministic validation sampling from changing training RNG.""" + torch.random.set_rng_state(self._training_rng_state) + if self._training_cuda_rng_states is not None: + torch.cuda.set_rng_state_all(self._training_cuda_rng_states) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/base.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8e47fd5a045105016798dbd037468c6188f4dab8 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/lightning/base.py @@ -0,0 +1,220 @@ +import copy + +import hydra +import pytorch_lightning as pl +import torch +from omegaconf import DictConfig + +from morph.dataset.core.representation import Representation +from morph.dataset.core.vocab import Distributions, Vocab +from morph.model.lightning._optimizer import OptimizerMixin +from morph.model.lightning._sampling import SamplingMixin +from morph.model.lightning._setup import SetupMixin +from morph.model.lightning._training import TrainingMixin +from morph.model.lightning._validation import ValidationMixin +from morph.utils.edge_alignment import EDGE_ALIGNER +from morph.utils.loss_accumulation import ( + InverseSquaredTimeLossWeighting, + LossAccumulator, + UnifiedWeightedLoss, +) +from morph.utils.lr_schedulers import EMADecayScheduler +from morph.utils.metrics import MetricCollection + + +class LightningModuleRates( + SetupMixin, + OptimizerMixin, + TrainingMixin, + ValidationMixin, + SamplingMixin, + pl.LightningModule, +): + """Train endpoint predictions and edit posteriors for molecular flow matching. + + Deletion and substitution heads predict binary logits, supervised by the + positivity of numerical lambda targets. The insertion-count head predicts + the number of pending atoms per spawning node using Poisson NLL. A unimodal + or mixture head predicts their clean positions, atom types, and charges. + Insertion-edge training uses target inserted-atom features; sampling uses + predicted features before applying insertion noise. + + Position and charge heads predict the clean endpoint. The integrator combines + edit posteriors with schedule masses to advance the molecular state. + See ``src/morph/model/README.md`` for tensor and loss contracts. + """ + + def training_step(self, batch, batch_idx): + """Dispatch explicitly to automatic or manual optimizer stepping.""" + if self.automatic_optimization: + return self._automatic_training_step(batch, batch_idx) + return self._manual_training_step(batch, batch_idx) + + LOSS_GROUPS: dict[str, list[str]] = { + "sub": ["do_sub_a", "sub_a_class", "do_sub_e", "sub_e_class"], + "del": ["do_del"], + "ins": ["ins_rate", "ins_state", "ins_e", "ins_e_ii"], + "x": ["x"], + "c": ["c"], + } + + def __init__( + self, + model: DictConfig, + integrator: DictConfig, + loss_weights: DictConfig, + optimizer_config: DictConfig, + insertion_head_mode: str, + vocab: Vocab, + distributions: Distributions, + loss_weight_distributions: Distributions, + atom_type_weights: torch.Tensor, + edge_token_weights: torch.Tensor, + charge_token_weights: torch.Tensor, + time_dist: DictConfig, + metrics: MetricCollection, + stability_metrics: MetricCollection, + distribution_metrics: MetricCollection | None = None, + batch_metrics: MetricCollection | None = None, + ins_noise_scale: float = 0.25, + use_learnable_loss_weights: bool = False, + ema_decay: float = 0.999, + ema_warmup_steps: int = 5000, + ema_decay_scheduler: EMADecayScheduler | DictConfig | None = None, + use_ema_for_eval: bool = True, + use_time_weights: bool = False, + allow_charged: bool = False, + log_grad_norms_every_n_steps: int = 0, + grad_norm_log_interval: int = 500, + grad_norms_granularity: str = "component", + representation: str | Representation = Representation.MOLECULE, + ): + super().__init__() + + self.model = hydra.utils.instantiate(model) + + # vocab and distributions + self.vocab = vocab + self.distributions = distributions + self.loss_weight_distributions = loss_weight_distributions + + self.ins_noise_scale = ins_noise_scale + + # Whether charged species are considered chemically valid for the dataset + # (e.g. QM9 contains only neutral molecules; GEOM contains charged ones). + self.allow_charged = allow_charged + + # Controls mode-aware losses, validation metrics, and prediction cleanup. + self.representation = Representation(representation) + + # setup ema scheduler + self.ema_decay = float(ema_decay) + self.ema_warmup_steps = max(0, int(ema_warmup_steps)) + if isinstance(ema_decay_scheduler, DictConfig): + ema_decay_scheduler = hydra.utils.instantiate(ema_decay_scheduler) + self.ema_decay_scheduler = ema_decay_scheduler + self.use_ema_for_eval = use_ema_for_eval + + if insertion_head_mode not in {"unimodal", "gmm"}: + raise ValueError( + "insertion_head_mode must be 'unimodal' or 'gmm', got " + f"{insertion_head_mode!r}" + ) + self.insertion_head_mode = insertion_head_mode + self.time_dist = hydra.utils.instantiate(time_dist) + + self.integrator = hydra.utils.instantiate( + integrator, distributions=self.distributions + ) + + # weights for class losses + self.register_buffer("atom_type_weights", atom_type_weights) + self.register_buffer("edge_token_weights", edge_token_weights) + self.register_buffer("charge_token_weights", charge_token_weights) + + # metrics tracking for validation + self.metrics = metrics + self.stability_metrics = stability_metrics + # Distribution metrics accumulate over the entire validation epoch so we + # can log a single pooled KL and render marginal plots at epoch end. + self.distribution_metrics = distribution_metrics + # Tensor-based metrics for non-topology modes. Updated in validation_step + # and computed/logged in on_validation_epoch_end. + self.batch_metrics = batch_metrics + + # Exponential moving average of model parameters for stable inference + self.model_ema = copy.deepcopy(self.model) + for p in self.model_ema.parameters(): + p.requires_grad = False + self.model_ema.eval() + + # handling of edge utilities, especially for upper-triangular handling + self.edge_aligner = EDGE_ALIGNER + + # set up loss weighting for individual loss components + loss_weight_values = {k: float(v) for k, v in loss_weights.items()} + self.loss_weight_wrapper = UnifiedWeightedLoss( + manual_weights=loss_weight_values, + component_keys=list(loss_weight_values.keys()), + use_learnable=use_learnable_loss_weights, + ) + + # optionally weigh by interpolation time + if use_time_weights: + time_weights = { + "x": InverseSquaredTimeLossWeighting(clamp_max=100.0), + "c": lambda t: self.integrator.sub_schedule.rate(t).clamp(max=100.0), + "ins": lambda t: self.integrator.ins_schedule.rate(t).clamp(max=100.0), + "del": lambda t: self.integrator.del_schedule.rate(t).clamp(max=100.0), + "sub": lambda t: self.integrator.sub_schedule.rate(t).clamp(max=100.0), + } + else: + # always use time-weighting for x and c + time_weights = { + "x": InverseSquaredTimeLossWeighting(clamp_max=100.0), + "c": lambda t: self.integrator.sub_schedule.rate(t).clamp(max=100.0), + } + + # object to tie loss computation, weighting, and logging together + self.loss_accumulator = LossAccumulator( + self.loss_weight_wrapper, self.LOSS_GROUPS, time_weights + ) + + self.optimizer_config = optimizer_config + self._setup_optimization() + + if hasattr(self.model, "initialize_output_priors"): + # Dataset categorical marginals initialize the DiT prediction + # heads; keep the EMA copy exactly synchronized with that model. + self.model.initialize_output_priors(self.loss_weight_distributions) + self.model_ema.load_state_dict(self.model.state_dict()) + + # Per-loss gradient-norm logging. Each invocation runs one extra + # backward per reported term, so this is rate-limited via a step + # interval and is off by default. + self.log_grad_norms_every_n_steps = int(log_grad_norms_every_n_steps) + # Cadence for the cheap global + per-group grad-norm logging in + # on_before_optimizer_step (two full per-parameter .norm() passes). Pure + # diagnostics (gradient clipping is separate and always runs), so log them + # every N steps rather than every step. <=0 disables. global_step is + # DDP-synchronized, so every rank logs the same keys on the same steps. + self.grad_norm_log_interval = int(grad_norm_log_interval) + if grad_norms_granularity not in {"component", "group", "both"}: + raise ValueError( + "grad_norms_granularity must be one of component/group/both, " + f"got {grad_norms_granularity!r}" + ) + self.grad_norms_granularity = grad_norms_granularity + + self.is_compiled = False + + # lastly, save hyperparameters + self.save_hyperparameters( + ignore=[ + "ema_decay_scheduler", + "metrics", + "stability_metrics", + "distribution_metrics", + "batch_metrics", + ] + ) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..79a67d948e266cb0dbd5affcec56d4ecb1cf1ced --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__init__.py @@ -0,0 +1,11 @@ +"""Molecular objectives shared by standalone and external trainers. + +``FlowLossMixin.loss_from_predictions`` assembles the complete objective from +existing head outputs. ``functional`` contains the individual mathematical +losses; records, weighting, and aggregation live in +``morph.utils.loss_accumulation``. +""" + +from morph.model.losses.flow import FlowLossMixin + +__all__ = ["FlowLossMixin"] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..571006ba5a528f05efa9253c3b45a46dd42ba38c Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/flow.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/flow.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10b0549e3c10401dfc8e9152718dba23d64c9820 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/flow.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/functional.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/functional.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42631ba684653c343fe1ec7d85709e35fa5b2b56 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/__pycache__/functional.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/flow.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/flow.py new file mode 100644 index 0000000000000000000000000000000000000000..a7ceaeb5ff03121ac6d903f5b7005e0fcb652472 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/flow.py @@ -0,0 +1,478 @@ +"""Molecular flow losses from existing predictions, independent of Lightning.""" + +import torch +import torch.nn.functional as F + +from morph.dataset.core.insertion_data import InsertedAtoms +from morph.model.losses.functional import ( + class_loss, + do_action_loss, + rate_loss, + typed_gmm_loss, + unimodal_insertion_loss, + zero_dependency, +) +from morph.utils.graph_math import unsorted_segment_mean_and_count +from morph.utils.loss_accumulation import LossTerm + + +def _zero_module_dependency(module) -> torch.Tensor: + """Keep every parameter in DDP's autograd graph without full reductions.""" + return sum(zero_dependency(parameter) for parameter in module.parameters()) + + +class FlowLossMixin: + """Share Morph's molecular objective with trainers that own their forward pass. + + The host supplies ``model`` (with ``insertion_head``, ``ins_edge_head`` and + the two inserted-edge prediction methods), ``device``, ``representation``, + ``insertion_head_mode``, the atom/edge/charge token weights, and a + ``LossAccumulator`` named ``loss_accumulator``. This mixin neither constructs + modules nor owns logging, optimization, or a training lifecycle. + """ + + def _node_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + """Optional per-node bool mask: True = drop this node from + discrete non-deletion and substitution losses. + + Default ``None`` keeps all nodes. + """ + return None + + def _position_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + """Optional per-node bool mask: True = drop this node from x loss.""" + return None + + def _charge_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + """Default to the discrete mask; tasks can supervise charges separately.""" + return self._node_loss_exclusion_mask(mols_t) + + def loss_from_predictions(self, preds, batch) -> torch.Tensor: + """Return the weighted flow loss without running the model backbone. + + ``batch`` is ``(mols_t, mols_1, insertion_targets, t)``. Predictions use + the ordinary Morph head contract and must follow ``mols_t`` node and + directed-edge order; clean targets must share that ordering. The + insertion-edge heads still run on target inserted atoms and the supplied + ``h_latent``. All gradients through the predictions remain connected. + + Populate ``loss_accumulator`` with this batch's losses and diagnostics; + callers decide when and how to log them. Task-specific node and position + exclusion hooks apply exactly as in standalone training. + """ + mols_t, mols_1, ins_targets, t = batch + self.loss_accumulator.begin_batch() + + # Mask for nodes scheduled for deletion, reused for x, c, and a losses. + # Deletion nodes have their target type/charge frozen to the prior sample, so + # supervising them would bias predictions towards the prior distribution. + to_delete_mask = mols_t.lambda_del > 0.0 + position_mask = ~to_delete_mask + non_del_mask = position_mask + + # Optional per-node exclusion (e.g. scaffold atoms in fine-tuning). + # ``exclusion_mask`` is True at nodes that should NOT contribute to discrete + # non-deletion / substitution losses; defaults to ``None``. + exclusion_mask = self._node_loss_exclusion_mask(mols_t) + if exclusion_mask is not None: + exclusion_mask = exclusion_mask.bool() + non_del_mask = non_del_mask & ~exclusion_mask + + position_exclusion_mask = self._position_loss_exclusion_mask(mols_t) + if position_exclusion_mask is not None: + position_mask = position_mask & ~position_exclusion_mask.bool() + + charge_mask = ~to_delete_mask + charge_exclusion_mask = self._charge_loss_exclusion_mask(mols_t) + if charge_exclusion_mask is not None: + charge_mask = charge_mask & ~charge_exclusion_mask.bool() + charge = self._charge_loss(preds["charge_head"], mols_1, mols_t, charge_mask) + losses = self._substitution_losses( + preds, mols_t, mols_1, non_del_mask, to_delete_mask, exclusion_mask + ) + losses.update( + self._insertion_deletion_losses(preds, ins_targets, mols_t, non_del_mask) + ) + losses["x"] = self._position_loss( + preds["pos_head"], mols_1, mols_t, position_mask + ) + losses["c"] = charge + self.loss_accumulator.set_batch_losses(losses, t=t) + + # Keep on-device (detach only); Lightning offloads these at log time. + # A per-step .item() here forces a blocking GPU->CPU sync every step. + self.loss_accumulator.add_stats( + { + "n_ins": (mols_t.lambda_ins > 0.0).sum().detach().float(), + "n_del": (mols_t.lambda_del > 0.0).sum().detach().float(), + } + ) + + loss = self.loss_accumulator.total_loss() + + # Keep every ins_edge_head parameter in the autograd graph for DDP. + loss = loss + _zero_module_dependency(self.model.ins_edge_head) + + return loss + + def _charge_loss(self, c_pred, mols_1, mols_t, non_del_mask): + """Per-graph charge classification loss (skipped in pointcloud mode).""" + # NOTE take loss on all non-deletion nodes + if self.representation.requires_charges: + c_loss, c_batch_mask = class_loss( + c_pred, + mols_1.c, + self.charge_token_weights, + non_del_mask, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + else: + # Pointcloud mode: c is the neutral dummy charge. Skip the loss + # entirely (no information content) but keep charge_head in the graph. + c_loss = torch.zeros( + mols_t.num_graphs, 1, device=self.device + ) + zero_dependency(c_pred) + c_batch_mask = torch.zeros( + mols_t.num_graphs, dtype=torch.bool, device=self.device + ) + return LossTerm(c_loss, c_batch_mask) + + def _substitution_losses( + self, preds, mols_t, mols_1, non_del_mask, to_delete_mask, exclusion_mask + ): + """Atom-type and edge-type substitution losses (do-action + class).""" + a_pred = preds["atom_type_head"] + e_pred = preds["edge_type_head"] + do_sub_a_head = preds["do_sub_a_head"] + do_sub_e_head = preds["do_sub_e_head"] + + # Atom substitutions + # NOTE take loss on all nodes + do_sub_a_loss = do_action_loss( + do_sub_a_head, + mols_t.lambda_a_sub, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + # NOTE take loss on all non-deletion & to-be-substituted nodes + # del nodes have lambda_a_sub = 0 anyway, this is just for clarity + to_sub_mask = (non_del_mask) & (mols_t.lambda_a_sub > 0.0) + sub_a_class_loss, sub_a_batch_mask = class_loss( + a_pred, + mols_1.a, + self.atom_type_weights, + to_sub_mask, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + # Edge substitutions + if self.representation.requires_topology: + # Interpolation creates the clean target with a clone of the current + # state's topology, and collation preserves that ordering. Select the + # shared upper triangle directly instead of sorting both edge sets. + edge_index = mols_t.edge_index + # Boolean gathers each perform a synchronizing nonzero on CUDA. + # Derive the shared index once for all six edge tensors. + upper = (edge_index[0] < edge_index[1]).nonzero(as_tuple=True)[0] + edge_index_triu = edge_index[:, upper] + e_batch_triu = mols_t.batch[edge_index_triu[0]] + e_pred_triu = e_pred[upper] + do_sub_e_head_triu = do_sub_e_head[upper] + e_target_triu = mols_1.e[upper] + do_sub_e_target_triu = mols_t.lambda_e_sub[upper] + + # Supervise edge classes only on substituted, non-deletion edges. + # Edges where either endpoint is scheduled for deletion are excluded: + # their targets are frozen to the source value, so supervising e_pred + # would train it to predict prior edge types — the wrong signal. + _src_idx, _dst_idx = edge_index_triu + non_del_edge_mask = ~to_delete_mask[_src_idx] & ~to_delete_mask[_dst_idx] + if exclusion_mask is not None: + # Exclude edges where BOTH endpoints are excluded (matches inference + # masking: scaffold-scaffold edges can never change). + non_del_edge_mask = non_del_edge_mask & ~( + exclusion_mask[_src_idx] & exclusion_mask[_dst_idx] + ) + + do_sub_e_loss = do_action_loss( + do_sub_e_head_triu, + do_sub_e_target_triu, + e_batch_triu, + mols_t.num_graphs, + reduction="none", + ) + + to_sub_edge_mask = (non_del_edge_mask) & (do_sub_e_target_triu > 0.0) + sub_e_class_loss, sub_e_batch_mask = class_loss( + e_pred_triu, + e_target_triu, + self.edge_token_weights, + to_sub_edge_mask, + e_batch_triu, + mols_t.num_graphs, + reduction="none", + ) + else: + # Non-topology modes: runtime edges carry the inert token + # and lambda_e_sub is identically zero, so the supervised edge-loss + # path is both informationless and a DDP risk (the class_loss + # short-circuit would drop edge_type_head from the autograd graph). + # Use explicit graph-preserving dummies on the head outputs instead. + do_sub_e_loss = torch.zeros( + mols_t.num_graphs, 1, device=self.device + ) + zero_dependency(do_sub_e_head) + sub_e_class_loss = torch.zeros( + mols_t.num_graphs, 1, device=self.device + ) + zero_dependency(e_pred) + sub_e_batch_mask = torch.zeros( + mols_t.num_graphs, dtype=torch.bool, device=self.device + ) + + return { + "do_sub_a": LossTerm(do_sub_a_loss), + "sub_a_class": LossTerm(sub_a_class_loss, sub_a_batch_mask), + "do_sub_e": LossTerm(do_sub_e_loss), + "sub_e_class": LossTerm(sub_e_class_loss, sub_e_batch_mask), + } + + def _insertion_deletion_losses(self, preds, ins_targets, mols_t, non_del_mask): + """Combine deletion decisions, insertion counts, content, and bond losses.""" + do_del_loss = do_action_loss( + preds["do_del_head"], + mols_t.lambda_del, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + # Supervise insertion counts at every node, including zero-count nodes. + ins_loss_rate = rate_loss( + preds["ins_rate_head"], + mols_t.n_ins, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + if ins_targets.spawn_node_idx.numel() > 0: + state = self._insertion_content_loss( + preds["insertion_head"], ins_targets, mols_t + ) + edges = self._insertion_edge_losses( + preds, ins_targets, mols_t, non_del_mask + ) + else: + empty = torch.zeros(mols_t.num_graphs, 1, device=self.device) + mask = torch.zeros(mols_t.num_graphs, dtype=torch.bool, device=self.device) + state = LossTerm( + empty + _zero_module_dependency(self.model.insertion_head), mask + ) + edges = {"ins_e": LossTerm(empty, mask), "ins_e_ii": LossTerm(empty, mask)} + return { + "do_del": LossTerm(do_del_loss), + "ins_rate": LossTerm(ins_loss_rate), + "ins_state": state, + **edges, + } + + def _insertion_content_loss(self, insertion_pred, ins_targets, mols_t): + """Supervise nearest insertions or all mixture targets, then reduce by graph.""" + spawn_node_idx = ins_targets.spawn_node_idx + if self.insertion_head_mode == "unimodal": + # One-to-one supervision: each spawning node predicts only its + # single nearest pending insertion (no mixture marginalization). + node_idx = ins_targets.nearest_node_idx + ins_idx = ins_targets.nearest_ins_idx + uni_dict_pred = { + "mu": insertion_pred["mu"][node_idx], + "a_logits": insertion_pred["a_logits"][node_idx], + "c_logits": insertion_pred["c_logits"][node_idx], + } + + uni_loss, uni_components = unimodal_insertion_loss( + uni_dict_pred, + ins_targets.positions[ins_idx], + ins_targets.atom_types[ins_idx], + ins_targets.charge_types[ins_idx], + self.atom_type_weights, + self.charge_token_weights, + reduction="none", + include_c=self.representation.requires_charges, + return_components=True, + ) + + ins_loss_state = uni_loss.view(-1) + + # Diagnostic: log the per-component means (over the supervised + # nearest nodes) so we can see whether position or atom-type/ + # charge dominates the next-atom loss. These share the single + # ins_state weight; they are stats, not separately-weighted terms. + self.loss_accumulator.add_stat( + "ins_pos", uni_components["pos"].mean().detach() + ) + self.loss_accumulator.add_stat( + "ins_type", uni_components["type"].mean().detach() + ) + if "charge" in uni_components: + self.loss_accumulator.add_stat( + "ins_charge", uni_components["charge"].mean().detach() + ) + # Keep c_logits in the autograd graph for DDP when dropped. + if not self.representation.requires_charges: + ins_loss_state = ins_loss_state + zero_dependency( + uni_dict_pred["c_logits"] + ) + + # reduce the loss to per-graph level (key on the nearest nodes) + ins_loss_state, supervised_count = unsorted_segment_mean_and_count( + ins_loss_state.view(-1, 1), + mols_t.batch[node_idx], + mols_t.num_graphs, + ) + else: + # Each pending insertion is supervised at its assigned spawning node. + gmm_pred = { + "mu": insertion_pred["mu"][spawn_node_idx], + "sigma": insertion_pred["sigma"][spawn_node_idx], + "pi": insertion_pred["pi"][spawn_node_idx], + "a_probs": insertion_pred["a_probs"][spawn_node_idx], + "c_probs": insertion_pred["c_probs"][spawn_node_idx], + } + + gmm_loss, _ = typed_gmm_loss( + gmm_pred, + ins_targets.positions, + ins_targets.atom_types, + ins_targets.charge_types, + self.atom_type_weights, + self.charge_token_weights, + reduction="none", + include_c=self.representation.requires_charges, + ) + + ins_loss_state = gmm_loss.view(-1) + # Keep c_probs in the autograd graph for DDP when its term is dropped. + if not self.representation.requires_charges: + ins_loss_state = ins_loss_state + zero_dependency(gmm_pred["c_probs"]) + + # reduce the loss to per-graph level + ins_loss_state, supervised_count = unsorted_segment_mean_and_count( + ins_loss_state.view(-1, 1), + mols_t.batch[spawn_node_idx], + mols_t.num_graphs, + ) + + return LossTerm(ins_loss_state, supervised_count > 0) + + def _insertion_edge_losses(self, preds, ins_targets, mols_t, non_del_mask): + """Supervise bonds from future atoms to current atoms and to each other.""" + ins_loss_e = torch.zeros( + mols_t.num_graphs, 1, device=self.device, dtype=preds["ins_rate_head"].dtype + ) + ins_loss_e_ii = torch.zeros_like(ins_loss_e) + ins_e_batch_mask = torch.zeros( + mols_t.num_graphs, dtype=torch.bool, device=self.device + ) + ins_e_ii_batch_mask = torch.zeros_like(ins_e_batch_mask) + # Compute insertion edge loss if available + # Index spaces: + # - spawn/existing indices address current-state nodes in mols_t + # - local indices address inserted nodes in ins_targets (future nodes) + # - ins_edge_types provides integer edge-class supervision targets + spawn_idx = ins_targets.bonds_to_existing.spawn_idx + existing_idx = ins_targets.bonds_to_existing.existing_idx + + # Bundle the inserted-atom features once; gather per-edge subsets + # below by indexing instead of repeating x/a/c gathers. + ins_feats = InsertedAtoms( + ins_targets.positions, ins_targets.atom_types, ins_targets.charge_types + ) + + # Insertion-edge prediction is only meaningful when chemical bonds + # are part of the representation. In non-topology modes, skip the + # head call entirely and emit a parameter-sum dummy so DDP still + # sees ins_edge_head as participating. + if ( + self.representation.requires_topology + and spawn_idx.numel() > 0 + and existing_idx.numel() > 0 + ): + ins_edge_non_del = non_del_mask[spawn_idx] & non_del_mask[existing_idx] + # Get edge predictions using the insertion edge head. Empty + # supervision masks are handled tensor-natively by class_loss; + # branching on ``.any()`` here would synchronize the GPU. + ins_edge_logits = self.model.predict_inserted_to_existing_edges( + mols_t=mols_t, + out_dict=preds, + spawn_node_idx=spawn_idx, + existing_node_idx=existing_idx, + # Gather per-edge inserted attrs from canonical future nodes. + # NOTE predict edge types for future nodes, not current nodes + # NOTE then at inference we will adjust to current noise level + ins=ins_feats[ins_targets.bonds_to_existing.inserted_idx], + ) + if ins_edge_logits is not None and ins_edge_logits.numel() > 0: + # Reuse the shared class loss helper (same masking + + # per-graph averaging) to compute insertion edge loss. + ins_loss_e, ins_e_batch_mask = class_loss( + ins_edge_logits, + ins_targets.bonds_to_existing.bond_type, + self.edge_token_weights, + ins_edge_non_del, + mols_t.batch[spawn_idx], + mols_t.num_graphs, + reduction="none", + ) + # Compute ins -> ins edge loss (gated on topology for the same reason). + spawn_src_ii = ins_targets.bonds_between_inserted.spawn_src_idx + spawn_dst_ii = ins_targets.bonds_between_inserted.spawn_dst_idx + if self.representation.requires_topology and spawn_src_ii.numel() > 0: + ii_non_del = non_del_mask[spawn_src_ii] & non_del_mask[spawn_dst_ii] + edges_ii_src = ins_targets.bonds_between_inserted.src_idx + edges_ii_dst = ins_targets.bonds_between_inserted.dst_idx + predictor = self.model.predict_inserted_to_inserted_edges + ins_ii_logits = predictor( + mols_t=mols_t, + out_dict=preds, + spawn_src_idx=spawn_src_ii, + spawn_dst_idx=spawn_dst_ii, + src=ins_feats[edges_ii_src], + dst=ins_feats[edges_ii_dst], + ) + if ins_ii_logits is not None and ins_ii_logits.numel() > 0: + # Reuse the shared class loss helper (same masking + + # per-graph averaging) to compute ins->ins edge loss. + ins_loss_e_ii, ins_e_ii_batch_mask = class_loss( + ins_ii_logits, + ins_targets.bonds_between_inserted.bond_type, + self.edge_token_weights, + ii_non_del, + mols_t.batch[spawn_src_ii], + mols_t.num_graphs, + reduction="none", + ) + + return { + "ins_e": LossTerm(ins_loss_e, ins_e_batch_mask), + "ins_e_ii": LossTerm(ins_loss_e_ii, ins_e_ii_batch_mask), + } + + def _position_loss(self, x_pred, mols_1, mols_t, position_mask): + """Per-graph coordinate MSE over the selected supervised nodes.""" + # The graph-preserving dependency below handles an empty selection. + x_loss = F.mse_loss( + x_pred[position_mask], mols_1.x[position_mask], reduction="none" + ) + x_loss, supervised_count = unsorted_segment_mean_and_count( + x_loss, mols_t.batch[position_mask], mols_t.num_graphs + ) + x_batch_mask = supervised_count > 0 + + # Unconditionally retain the position output in the graph. This is a + # no-op for ordinary batches and covers the all-deleted edge case without + # a blocking device-to-host ``x_batch_mask.any()`` branch. + x_loss = x_loss + zero_dependency(x_pred) + + return LossTerm(x_loss, x_batch_mask) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/functional.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/functional.py new file mode 100644 index 0000000000000000000000000000000000000000..c631817fe0d68a79eea1be16dcafc60fe7fdd097 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/model/losses/functional.py @@ -0,0 +1,327 @@ +import torch +import torch.nn.functional as F + +from morph.model.layers.gmm import get_typed_gmm_components +from morph.utils.graph_math import ( + unsorted_segment_mean, + unsorted_segment_mean_and_count, +) + + +def zero_dependency(value: torch.Tensor) -> torch.Tensor: + """Return scalar zero while retaining ``value`` in the autograd graph.""" + if value.numel() == 0: + return value.sum() * 0.0 + return value.reshape(-1)[0] * 0.0 + + +def reduce_loss(loss, reduction: str = "mean"): + if reduction == "mean": + return loss.mean() + elif reduction == "sum": + return loss.sum() + elif reduction == "none": + return loss + else: + raise ValueError(f"Invalid reduction: {reduction}") + + +def _type_log_prob_from_ce(target, dist, class_weights, name, N): + """Compute per-component type log-prob using PyTorch CE.""" + logits = dist.logits # [N, 1, K, T] + K = logits.shape[2] + num_classes = logits.shape[-1] + logits_flat = logits.reshape(-1, num_classes) + + if target.dim() == 1: + if target.shape[0] != N: + raise ValueError( + f"{name} hard targets must have shape [N], got {target.shape} for N={N}" + ) + target_idx = target.long() + target_idx_flat = target_idx.view(N, 1, 1).expand(N, 1, K).reshape(-1) + ce = F.cross_entropy( + logits_flat, + target_idx_flat, + weight=class_weights, + reduction="none", + ) + return -ce.view(N, 1, K) + + if target.dim() == 2: + if target.shape[0] != N: + raise ValueError( + f"{name} soft targets must have shape [N, T], " + f"got {target.shape} for N={N}" + ) + num_classes = dist.logits.shape[-1] + if target.shape[1] != num_classes: + raise ValueError( + f"{name} soft targets class dim mismatch: " + f"expected {num_classes}, got {target.shape[1]}" + ) + + target_probs = target.to(dtype=dist.logits.dtype) + target_probs = target_probs / target_probs.sum(dim=-1, keepdim=True).clamp_min( + 1e-8 + ) + target_probs_flat = ( + target_probs.view(N, 1, 1, num_classes) + .expand(N, 1, K, num_classes) + .reshape(-1, num_classes) + ) + ce = F.cross_entropy( + logits_flat, + target_probs_flat, + weight=class_weights, + reduction="none", + ) + return -ce.view(N, 1, K) + + raise ValueError( + f"{name} targets must be rank-1 indices or rank-2 " + f"probabilities, got rank {target.dim()}" + ) + + +def typed_gmm_loss( + gmm_output, + target_x, + target_a, + target_c, + class_weights_a, + class_weights_c, + reduction="mean", + include_c: bool = True, +): + """ + Computes the NLL loss using the get_typed_gmm_components helper. + + When ``include_c=False`` the joint log-probability omits the charge term + in representations without charges. The c sub-head's outputs + still need a DDP-safe dummy elsewhere — see the lightning module. + """ + + # Create component distributions. The helper treats the first dimension as + # batch; here that "batch" is the valid insertion targets, so returned + # distributions have batch_shape [N, 1, K]. + mix_dist, x_dist, a_dist, c_dist = get_typed_gmm_components(gmm_output) + + N = mix_dist.logits.shape[0] + D = target_x.shape[-1] + + # Spatial target: [N, D] -> [N, 1, 1, D], broadcasting against K components. + target_x = target_x.view(N, 1, 1, -1) + + # Per-component log-probabilities, all shaped [N, 1, K]. + log_prob_x = x_dist.log_prob(target_x) / D + log_prob_a = _type_log_prob_from_ce( + target_a, a_dist, class_weights_a, "target_a", N + ) + # The mixture distribution already stores normalized log pi for each component. + log_prob_mix = mix_dist.logits + + # Joint log-prob before marginalizing over mixture components. In non-charge + # modes, omit the c term rather than training on neutral dummy targets. + log_joint = log_prob_mix + log_prob_x + log_prob_a + if include_c: + log_prob_c = _type_log_prob_from_ce( + target_c, c_dist, class_weights_c, "target_c", N + ) + log_joint = log_joint + log_prob_c + + # Marginalize over components: log sum_k p(k, x, a[, c]). + log_likelihood = torch.logsumexp(log_joint, dim=-1) + + if reduction == "mean": + nll = -torch.mean(log_likelihood) + elif reduction == "sum": + nll = -torch.sum(log_likelihood) + elif reduction == "none": + nll = -log_likelihood + else: + raise ValueError(f"Invalid reduction: {reduction}") + + ones = torch.ones((N, 1, 1), device=target_x.device, dtype=target_x.dtype) + return nll, (ones, ones) + + +def _ce_per_node(logits, target, class_weights): + """Weighted cross-entropy per row, supporting hard (rank-1) or soft (rank-2) + targets. Mirrors the target handling in :func:`_type_log_prob_from_ce` but + for a single (non-mixture) distribution; returns one scalar per row. + """ + if target.dim() == 1: + return F.cross_entropy( + logits, target.long(), weight=class_weights, reduction="none" + ) + if target.dim() == 2: + probs = target.to(dtype=logits.dtype) + probs = probs / probs.sum(dim=-1, keepdim=True).clamp_min(1e-8) + return F.cross_entropy(logits, probs, weight=class_weights, reduction="none") + raise ValueError( + f"insertion type targets must be rank-1 indices or rank-2 probabilities, " + f"got rank {target.dim()}" + ) + + +def unimodal_insertion_loss( + uni_output, + target_x, + target_a, + target_c, + class_weights_a, + class_weights_c, + reduction="mean", + include_c: bool = True, + return_components: bool = False, +): + """Per-node insertion loss for the unimodal (nearest-insertion) head. + + Counterpart to :func:`typed_gmm_loss` for the ``mode == "unimodal"`` + path. ``uni_output`` carries ``mu`` ``[M, 3]``, ``a_logits`` ``[M, N_a]`` and + ``c_logits`` ``[M, N_c]``, already gathered at the ``M`` supervised nodes + (each node's single nearest pending insertion). Position is a fixed-variance + Gaussian (MSE), averaged over spatial dims so its scale matches the GMM + path's per-dimension log-prob (``/D``); atom type and charge are weighted CE. + + When ``include_c=False`` the charge term is dropped (non-charge modes); the + caller is responsible for keeping ``c_logits`` in the autograd graph for DDP. + + When ``return_components=True`` also returns a dict of the *unreduced* + per-node component tensors (``{"pos", "type"[, "charge"]}``) so the caller can + log them separately for diagnostics; they share the single ``ins_state`` weight. + """ + mu = uni_output["mu"] + a_logits = uni_output["a_logits"] + c_logits = uni_output["c_logits"] + D = target_x.shape[-1] + + # Fixed-variance Gaussian NLL == MSE up to constants; normalize by D so the + # spatial term is on the same per-dimension scale as the GMM log_prob_x / D. + loss_x = F.mse_loss(mu, target_x, reduction="none").sum(dim=-1) / D # [M] + loss_a = _ce_per_node(a_logits, target_a, class_weights_a) # [M] + + loss = loss_x + loss_a + components = {"pos": loss_x, "type": loss_a} + if include_c: + loss_c = _ce_per_node(c_logits, target_c, class_weights_c) + loss = loss + loss_c + components["charge"] = loss_c + + reduced = reduce_loss(loss, reduction) + if return_components: + return reduced, components + return reduced + + +def do_action_loss( + do_action_pred, + num_actions, + batch, + num_graphs, + reduction: str = "mean", +): + """Calculate the do action loss for the given do action predictions. + Is used for all actions (substitutions, deletions). + + Args: + do_action_pred: The predicted do action values. + num_actions: The number of actions for each node. + batch: The batch indices for each node. + num_graphs: The number of graphs in the batch. + reduction: "mean" for a scalar or "none" for per-graph values. + """ + do_action = num_actions > 0.0 + + # NOTE: we use BCEWithLogitsLoss for scalar logit predictions + # do_action_pred is shape (N,) with logits + do_action_loss = F.binary_cross_entropy_with_logits( + do_action_pred.view(-1), + do_action.float().view(-1), + reduction="none", + ) + do_action_loss = unsorted_segment_mean( + do_action_loss.view(-1, 1), batch, num_graphs + ) + + return reduce_loss(do_action_loss, reduction) + + +def rate_loss(rate_pred, num_actions, batch, num_graphs, reduction: str = "mean"): + """ + Calculate the Poisson NLL rate loss for insertion predictions. + + Args: + rate_pred: The predicted rate values, shape (N,) or (N, 1). + num_actions: The integer number of actions for each node. + batch: The batch indices for each node. + num_graphs: The number of graphs in the batch. + reduction: "mean" for a scalar or "none" for per-graph values. + """ + + # Apply Poisson NLL to ALL nodes, including those with zero insertions. + rate_loss = F.poisson_nll_loss( + rate_pred.view(-1), + num_actions.view(-1), + log_input=False, + reduction="none", + full=True, + ) + + # NOTE: first normalize by number of nodes / graphs + # Otherwise, nodes with more atoms will have more weight by design + rate_loss = unsorted_segment_mean(rate_loss.view(-1, 1), batch, num_graphs) + + return reduce_loss(rate_loss, reduction) + + +def class_loss( + class_pred, + class_target, + class_weights, + do_action_mask, + batch, + num_graphs, + reduction: str = "mean", +): + """ + Calculate the class loss. Is not applied to masked nodes / edges. + Concretely: + c: class_loss is applied to all non-del nodes. + a: class_loss is applied to all non-del & to-be-substituted nodes. + e: class_loss is applied to edges between non-del nodes that are substituted. + ins_e : class_loss is applied to all edges between ins & non-del nodes. + ins_e_ii: class_loss is applied to all edges between ins & ins nodes. + """ + # 1. Compute CE ONLY on the nodes that need modification. Indexing with a + # boolean mask runs an internal ``nonzero`` -- a device sync with a + # data-dependent output size -- so derive the index ONCE and reuse it for + # every gather instead of re-deriving it per tensor (class_pred / target / + # batch). No empty-mask branch is needed: cross_entropy over an empty + # selection returns an empty tensor and unsorted_segment_mean over it returns + # zeros (count is clamped to >=1), so the loss is naturally 0 -- without the + # host sync a Python-side ``do_action_mask.any()`` would force every call. + idx = do_action_mask.nonzero(as_tuple=True)[0] + masked_loss = F.cross_entropy( + class_pred[idx], + class_target[idx], + weight=class_weights, + reduction="none", + ) + + # 2. Pool the masked losses per graph. + cls_loss, supervised_count = unsorted_segment_mean_and_count( + masked_loss.view(-1, 1), batch[idx], num_graphs + ) + + # 3. Keep class_pred in the autograd graph unconditionally: DDP rejects + # parameters with grad=None after backward. For a non-empty mask the gathered + # CE already does this; the ``+ 0.0 * class_pred.sum()`` term also covers the + # empty-mask case (zero gradient, populated) without a data-dependent branch. + cls_loss = cls_loss + zero_dependency(class_pred) + + # 4. Determine which graphs had modifications. + batch_has_modified = supervised_count > 0 + + return reduce_loss(cls_loss, reduction), batch_has_modified diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29bbd812cde4782119dc27b0c2a7073a93ba50c9 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities: tensor/graph helpers, RDKit conversion, metrics, viz.""" diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f7ee37c0590e784ca19d27585a67360c6a72487 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/checkpoints.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/checkpoints.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..493e8ce169c1af7e12e41d81ee974fe51c91755d Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/checkpoints.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/edge_alignment.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/edge_alignment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc835f620ecd24fe5db295f811b10839a75e8c56 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/edge_alignment.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/graph_math.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/graph_math.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33e9692bec850a2c90d3088778bddc2e990a0444 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/graph_math.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/loss_accumulation.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/loss_accumulation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..13b5fffd1f218f4b89f4deb2416282bdebcfd9fd Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/loss_accumulation.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/lr_schedulers.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/lr_schedulers.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8abd52d6c1191fe25cdcd8496b0e6d4a921bcb0a Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/lr_schedulers.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/rdkit_utils.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/rdkit_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..683023faf70152b53df639f37669396ad8fbc4d0 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/rdkit_utils.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/vocab_utils.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/vocab_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b12b2c05ba7235c144cab6ee3650568eccb42768 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/__pycache__/vocab_utils.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/checkpoints.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..4b6b6fd189f1ef5a8194e09c3104231f52b7f662 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/checkpoints.py @@ -0,0 +1,61 @@ +"""Checkpoint compatibility across eager and ``torch.compile`` modules.""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + + import torch + + +def canonical_checkpoint_key(key: str) -> str: + """Remove implementation-detail ``_orig_mod`` path components.""" + return ".".join(part for part in key.split(".") if part != "_orig_mod") + + +def canonicalize_state_dict( + state_dict: Mapping[str, torch.Tensor], +) -> OrderedDict[str, torch.Tensor]: + """Return a compile-independent state dict and reject ambiguous keys.""" + canonical: OrderedDict[str, torch.Tensor] = OrderedDict() + source_keys: dict[str, str] = {} + for key, value in state_dict.items(): + canonical_key = canonical_checkpoint_key(key) + if canonical_key in canonical and source_keys[canonical_key] != key: + raise RuntimeError( + "Checkpoint keys collide after removing _orig_mod: " + f"{source_keys[canonical_key]!r} and {key!r}" + ) + canonical[canonical_key] = value + source_keys[canonical_key] = key + metadata = getattr(state_dict, "_metadata", None) + if metadata is not None: + canonical._metadata = { + canonical_checkpoint_key(key): value for key, value in metadata.items() + } + return canonical + + +def state_dict_for_module( + state_dict: Mapping[str, torch.Tensor], module: torch.nn.Module +) -> OrderedDict[str, torch.Tensor]: + """Map canonical checkpoint keys onto an eager or compiled module.""" + canonical = canonicalize_state_dict(state_dict) + target_state = module.state_dict() + target_keys = {canonical_checkpoint_key(key): key for key in target_state} + adapted = OrderedDict( + (target_keys.get(key, key), value) for key, value in canonical.items() + ) + metadata = getattr(canonical, "_metadata", None) + target_metadata = getattr(target_state, "_metadata", {}) + if metadata is not None: + target_metadata_keys = { + canonical_checkpoint_key(key): key for key in target_metadata + } + adapted._metadata = { + target_metadata_keys.get(key, key): value for key, value in metadata.items() + } + return adapted diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/edge_alignment.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/edge_alignment.py new file mode 100644 index 0000000000000000000000000000000000000000..b15835716bd6e19e8cb2a17af2741ce9c88199b1 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/edge_alignment.py @@ -0,0 +1,136 @@ +"""Upper-triangle and full-symmetric edge alignment. + +Prefer the module-level ``EDGE_ALIGNER`` singleton in hot loops. +""" + +import torch +from torch_geometric.utils import sort_edge_index + + +class EdgeAligner: + """ + Helper class to handle edge indices and attributes. + Can be used to align edge indices and attributes between two groups of edges. + + The class is stateless, so prefer the module-level ``EDGE_ALIGNER`` singleton + over instantiating new objects in hot loops (e.g. per-batch integration or + per-molecule join ops) to avoid unnecessary Python allocations. + """ + + def __init__(self): + pass + + def get_canonical_upper_triangle_with_index( + self, edge_index, edge_attr, include_diag=False + ): + """Returns (edge_index, edge_attr) for the upper triangle.""" + # 1. Sort first to ensure canonical order + edge_index, edge_attr = sort_edge_index(edge_index, edge_attr) + row, col = edge_index + + # 2. Filter + mask = row <= col if include_diag else row < col + + # Return BOTH the filtered index and attributes + return edge_index[:, mask], edge_attr[mask] + + def _process_edge_group(self, edge_index, attrs): + """Sort one edge group once and return its upper-triangle attributes.""" + if not attrs: + return None, [] + canonical_index, sorted_attrs = sort_edge_index(edge_index, attrs) + row, col = canonical_index + mask = row < col + return canonical_index[:, mask], [attr[mask] for attr in sorted_attrs] + + def align_edges(self, source_group, target_group=None): + """ + Flexible handler for aligning edge attributes. + + Args: + source_group: Tuple of (edge_index, [attr1, attr2, ...]) + target_group: (Optional) Tuple of (edge_index, [attr1, attr2, ...]) + + Returns: + Flat tuple of all processed attributes from source then target. + """ + src_index, src_attrs = source_group + tgt_index, tgt_attrs = (None, None) if target_group is None else target_group + + # 1. Process Source Group + src_triu_index, src_triu_attrs = self._process_edge_group(src_index, src_attrs) + + # 2. Process Target Group (if exists) + tgt_triu_attrs = [] + if target_group: + tgt_triu_index, tgt_triu_attrs = self._process_edge_group( + tgt_index, tgt_attrs + ) + + # Return all attributes flattened + out = { + "edge_index": (src_triu_index, tgt_triu_index), + "edge_attr": tuple(src_triu_attrs + tgt_triu_attrs), + } + + else: + out = { + "edge_index": src_triu_index, + "edge_attr": src_triu_attrs, + } + return out + + def symmetrize_edges( + self, + edge_index_triu: torch.Tensor, + attrs: list[torch.Tensor], + num_nodes: int | None = None, + ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]: + """ + Reverse: Upper Triangle -> Full Symmetric + Handles attributes efficiently by calculating the sort permutation once. + + ``num_nodes`` is the radix for the lexical sort key and only needs to + exceed the largest node index. Callers that already know the node count + (e.g. ``positions.shape[0]``) should pass it to skip a + ``full_edge_index.max().item()`` device sync in the hot integration loop. + """ + row, col = edge_index_triu + + # 1. Identify off-diagonal edges to mirror + mask = row < col + + # 2. Create the mirrored index + mirror_index = torch.stack([col[mask], row[mask]], dim=0) + full_edge_index = torch.cat([edge_index_triu, mirror_index], dim=1) + + # 3. Compute Sort Permutation ONCE + # Repeated sorts risk putting equal-key attributes out of sync. + # if the sort isn't stable. We compute indices explicitly. + # Simple Lexical Sort: row * max_cols + col + # (Or use PyTorch's stable sort on rows then cols) + if num_nodes is None: + num_nodes = int(full_edge_index.max().item()) + 1 + perm = (full_edge_index[0] * num_nodes + full_edge_index[1]).argsort() + + # Apply sort to index + full_edge_index_sorted = full_edge_index[:, perm] + + # 4. Process all attributes + full_attrs_sorted = [] + for attr in attrs: + # Mirror the attributes corresponding to the mask + mirror_attr = attr[mask] + full_attr = torch.cat([attr, mirror_attr], dim=0) + + # Apply the SAME permutation + full_attrs_sorted.append(full_attr[perm]) + + return full_edge_index_sorted, tuple(full_attrs_sorted) + + +# Stateless shared instance — reuse in hot paths instead of constructing new +# ``EdgeAligner()`` objects per batch / per molecule. + + +EDGE_ALIGNER = EdgeAligner() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/graph_math.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/graph_math.py new file mode 100644 index 0000000000000000000000000000000000000000..a3cbce3098c507a30512fd13db3ab3b7784e406c --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/graph_math.py @@ -0,0 +1,181 @@ +"""Tensor and edge-index helpers: Kabsch alignment and segment aggregation.""" + +from functools import cache + +import torch +from torch_geometric.utils import to_dense_adj + + +def edge_types_to_symmetric(edge_index, edge_types, num_atoms): + """ + Convert edge types to a symmetric adjacency matrix. + edge_index: Shape (2, E) - edge indices + edge_types: Shape (E) - edge types + num_atoms: int - number of atoms in the graph + Returns: + adj_matrix: Shape (N, N) - symmetric adjacency matrix + """ + adj_matrix = to_dense_adj(edge_index, edge_attr=edge_types, max_num_nodes=num_atoms) + adj_matrix = adj_matrix.squeeze() + return adj_matrix + + +def rigid_alignment(x_0, x_1, pre_centered=False): + """ + See: https://en.wikipedia.org/wiki/Kabsch_algorithm + Alignment of two point clouds using the Kabsch algorithm. + Based on: https://gist.github.com/bougui505/e392a371f5bab095a3673ea6f4976cc8 + """ + d = x_0.shape[1] + assert x_0.shape == x_1.shape, "x_0 and x_1 must have the same shape" + + # remove COM from data and record initial COM + if pre_centered: + x_0_mean = x_0.new_zeros((1, d)) + x_1_mean = x_1.new_zeros((1, d)) + x_0_c = x_0 + x_1_c = x_1 + else: + x_0_mean = x_0.mean(dim=0, keepdim=True) + x_1_mean = x_1.mean(dim=0, keepdim=True) + x_0_c = x_0 - x_0_mean + x_1_c = x_1 - x_1_mean + + # Covariance matrix + H = x_0_c.T.mm(x_1_c) + U, _, V = torch.svd(H) + # Rotation matrix + R = V.mm(U.T) + # Translation vector + t = x_0.new_zeros((1, d)) if pre_centered else x_1_mean - R.mm(x_0_mean.T).T + + return R, t + + +def unsorted_segment_sum(data, segment_ids, num_segments): + """Sum ``data`` rows into ``num_segments`` buckets keyed by ``segment_ids``. + + Equivalent to a scatter-add over dim 0. Uses ``index_add_``, which takes a + 1-D index and broadcasts over the feature columns — avoiding the + ``unsqueeze().expand()`` index materialization a ``scatter_add_`` needs. + + Args: + data: Shape (N, K) - values to aggregate. + segment_ids: Shape (N,) - segment index in [0, num_segments) per row. + num_segments: int - number of output segments. + + Returns: + Shape (num_segments, K); empty segments are 0. + """ + result = data.new_zeros((num_segments, data.size(1))) + result.index_add_(0, segment_ids, data) + return result + + +def unsorted_segment_mean(data, segment_ids, num_segments): + """Mean of ``data`` rows per segment; empty segments yield 0. + + Counts via ``bincount`` (one 1-D op of shape ``(num_segments,)``) instead of + scattering a full ``ones_like(data)``. + + Args: + data: Shape (N, K) - values to aggregate. + segment_ids: Shape (N,) - segment index in [0, num_segments) per row. + num_segments: int - number of output segments. + + Returns: + Shape (num_segments, K); empty segments are 0. + """ + return unsorted_segment_mean_and_count(data, segment_ids, num_segments)[0] + + +def unsorted_segment_mean_and_count(data, segment_ids, num_segments): + """Return per-segment means and the un-clamped number of contributing rows.""" + result = data.new_zeros((num_segments, data.size(1))) + result.index_add_(0, segment_ids, data) + count = torch.bincount(segment_ids, minlength=num_segments) + return result / count.clamp(min=1).unsqueeze(1), count + + +def build_fully_connected_edge_index(n, device=None): + """Directed, no-self-loop edge_index of shape ``(2, N*(N-1))``. + + Canonical home for the fully-connected edge builder (consolidated from a + second, divergent copy that used to live in ``dataset.core.representation``). + Guards the ``n <= 1`` case explicitly; ``device=None`` yields a CPU tensor. + """ + if n <= 1: + return torch.empty((2, 0), dtype=torch.long, device=device) + rng = torch.arange(n, device=device) + src, dst = torch.meshgrid(rng, rng, indexing="ij") + mask = src != dst + return torch.stack([src[mask], dst[mask]], dim=0) + + +@cache +def _complete_graph_layout(num_nodes: int, device: torch.device): + edge_index = build_fully_connected_edge_index(num_nodes, device=device) + triu_rows, triu_cols = torch.triu_indices( + num_nodes, num_nodes, offset=1, device=device + ) + row, col = edge_index + i, j = torch.minimum(row, col), torch.maximum(row, col) + full_to_triu = (i * (2 * num_nodes - i - 1)) // 2 + (j - i - 1) + return edge_index, triu_rows, triu_cols, full_to_triu + + +def complete_graph_layout(num_nodes: int, device=None): + """Return cached complete edges, upper-triangle rows/cols, and their mapping.""" + device = torch.device("cpu") if device is None else torch.device(device) + return _complete_graph_layout(num_nodes, device) + + +def batched_full_to_triu( + edge_index: torch.Tensor | tuple[torch.Tensor, torch.Tensor], + batch: torch.Tensor, + node_counts: torch.Tensor, +) -> torch.Tensor: + """Map complete directed edges to their batched upper triangles. + + Graph nodes must be contiguous and each graph's edges must be complete and + directed. Edge storage order is arbitrary. The returned indices let + ``edge_attr_triu[full_to_triu]`` restore full symmetric attributes without + rebuilding or sorting ``edge_index``. + """ + rows, cols = edge_index + graph = batch[rows] + node_starts = node_counts.cumsum(0) - node_counts + local_rows = rows - node_starts[graph] + local_cols = cols - node_starts[graph] + lower = torch.minimum(local_rows, local_cols) + upper = torch.maximum(local_rows, local_cols) + widths = node_counts[graph] + + triu_counts = node_counts * (node_counts - 1) // 2 + triu_starts = triu_counts.cumsum(0) - triu_counts + within_graph = lower * (2 * widths - lower - 1) // 2 + upper - lower - 1 + return triu_starts[graph] + within_graph + + +def batched_triu_edge_layout( + edge_index: torch.Tensor | tuple[torch.Tensor, torch.Tensor], + batch: torch.Tensor, + node_counts: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return one representative edge per pair and the full-to-triu mapping. + + This is shape-stable under ``torch.compile``: it avoids boolean indexing, + whose output length depends on tensor data, by selecting the first of each + pair of symmetric directed edges with an integer scatter reduction. + """ + full_to_triu = batched_full_to_triu(edge_index, batch, node_counts) + num_edges = edge_index[0].numel() + triu_edge_positions = full_to_triu.new_full((num_edges // 2,), num_edges) + triu_edge_positions.scatter_reduce_( + 0, + full_to_triu, + torch.arange(num_edges, device=edge_index[0].device), + reduce="amin", + include_self=True, + ) + return triu_edge_positions, full_to_triu diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/loss_accumulation.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/loss_accumulation.py new file mode 100644 index 0000000000000000000000000000000000000000..d4c8523585949eb02ae953c3597541216c9a0389 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/loss_accumulation.py @@ -0,0 +1,376 @@ +"""Per-graph loss records, time/component weights, and batch aggregation.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import torch +import torch.nn as nn + + +@dataclass(frozen=True) +class LossTerm: + """Per-graph loss values and the graphs included in their final mean. + + ``values`` has shape ``[B, ...]``; ``graph_mask`` is an optional boolean + tensor of shape ``[B]``. Empty supervision uses zero values and a false + mask, retaining any required autograd dependencies in ``values``. + """ + + values: torch.Tensor + graph_mask: torch.Tensor | None = None + + def __post_init__(self) -> None: + """Validate the batch dimension and optional boolean graph mask.""" + if self.values.ndim == 0: + raise ValueError("LossTerm requires per-graph values, not a scalar") + if self.graph_mask is not None: + if self.graph_mask.dtype != torch.bool: + raise ValueError("LossTerm graph_mask must be boolean") + if self.graph_mask.shape != self.values.shape[:1]: + raise ValueError("LossTerm graph_mask must have one entry per graph") + + +class InverseSquaredTimeLossWeighting: + """Clipped inverse-square weighting near the flow endpoint.""" + + def __init__(self, clamp_max: float = 10.0): + self.clamp_max = float(clamp_max) + + def __call__(self, t: torch.Tensor) -> torch.Tensor: + """Return clipped inverse-square weights for graph times.""" + w = 1.0 / ((1.0 - t + 1e-6) ** 2) + return torch.clamp(w, max=self.clamp_max) + + +class LearnableWeightedLoss(nn.Module): + """Weight component losses using learned Kendall-Gal log variances.""" + + def __init__(self, initial_weights, component_keys): + """Create Kendall-Gal log-variance weights from heuristic weights.""" + super().__init__() + self.component_keys = component_keys + s_values = [-math.log(initial_weights[key]) for key in component_keys] + self.s = nn.Parameter(torch.tensor(s_values, dtype=torch.float32)) + + def forward(self, losses_dict): + """Apply learned uncertainty weights to the available losses.""" + weights = torch.exp(-self.s) + total_loss = 0 + for i, key in enumerate(self.component_keys): + if key in losses_dict: + s_i = self.s[i] + total_loss += weights[i] * losses_dict[key] + 0.5 * s_i + return total_loss + + +class UnifiedWeightedLoss(nn.Module): + """Apply either fixed component weights or learned uncertainty weights.""" + + def __init__(self, manual_weights, component_keys, use_learnable=False): + super().__init__() + self.use_learnable = use_learnable + self.component_keys = component_keys + + if use_learnable: + eps = 1e-6 + initial_weights = { + key: max(float(manual_weights[key]), eps) + if manual_weights[key] > 0 + else eps + for key in component_keys + } + self.learnable_wrapper = LearnableWeightedLoss( + initial_weights, component_keys + ) + else: + self.register_buffer( + "manual_weights", + torch.tensor( + [manual_weights[key] for key in component_keys], + dtype=torch.float32, + ), + ) + self.learnable_wrapper = None + + def forward(self, losses_dict): + """Return the weighted sum of available loss components.""" + if self.use_learnable and self.learnable_wrapper is not None: + return self.learnable_wrapper(losses_dict) + total_loss = 0 + for i, key in enumerate(self.component_keys): + if key in losses_dict: + total_loss += self.manual_weights[i] * losses_dict[key] + return total_loss + + def get_weights(self): + """Return current component weights as Python floats.""" + weights = self._weight_vector().cpu().tolist() + return dict(zip(self.component_keys, weights, strict=True)) + + def _weight_vector(self) -> torch.Tensor: + """Return detached component weights on their owning device.""" + with torch.no_grad(): + if self.use_learnable and self.learnable_wrapper is not None: + return torch.exp(-self.learnable_wrapper.s) + return self.manual_weights + + def get_weight_tensors(self): + """Return component-weight views on their owning device.""" + return dict(zip(self.component_keys, self._weight_vector(), strict=True)) + + +class LossAccumulator: + """Per-step helper that collects raw losses, applies time and component weights, + and builds a uniform log dict separating pure losses from fully weighted losses. + + Args: + weight_module: ``UnifiedWeightedLoss`` instance that owns the component weights. + groups: Mapping from group name to list of component keys. + time_weight_modules: Mapping from GROUP NAME to a time-weighting callable. + """ + + def __init__( + self, + weight_module, + groups: dict[str, list[str]], + time_weight_modules: dict[str, callable] | None = None, + ): + self._weight_module = weight_module + self._groups = groups + self._time_weight_modules = time_weight_modules or {} + + # Reverse mapping: component_key -> group_name for fast lookups + self._key_to_group = { + key: group for group, keys in groups.items() for key in keys + } + + # Track pure and time-weighted losses separately + self._raw_losses: dict[str, torch.Tensor] = {} + self._tw_losses: dict[str, torch.Tensor] = {} + + self._stats: dict[str, torch.Tensor | float] = {} + self._current_time_weights: dict[str, torch.Tensor | float] = {} + + # ── Population ─────────────────────────────────────────────────── + + def begin_batch(self) -> None: + """Discard diagnostics left by the previous batch.""" + self._stats.clear() + + def set_batch_losses( + self, + batch_losses: dict[str, LossTerm], + t: torch.Tensor | None = None, + ): + """Apply per-graph time weights and masks, then reduce each named term.""" + self._raw_losses.clear() + self._tw_losses.clear() + self._current_time_weights.clear() + + # Precompute time-weight tensors + group_tw_tensors = {} + if t is not None: + for group, tw_module in self._time_weight_modules.items(): + tw = tw_module(t) + group_tw_tensors[group] = tw + # Keep on-device (detach only); .item() would force a per-step + # GPU->CPU sync. Lightning offloads it lazily at log time. + self._current_time_weights[group] = tw.mean().detach() + + for key, term in batch_losses.items(): + loss_b = term.values + if t is not None and loss_b.shape[0] != t.shape[0]: + raise ValueError(f"Loss {key!r} must have one row per graph time") + # 1. Apply time-weights to create a weighted copy + if t is not None: + group = self._key_to_group.get(key) + if group is not None and group in group_tw_tensors: + tw = group_tw_tensors[group] + target_shape = [-1] + [1] * (loss_b.ndim - 1) + loss_tw_b = loss_b * tw.view(*target_shape) + else: + loss_tw_b = loss_b + else: + loss_tw_b = loss_b + + # 2. Apply masking if provided + mask = term.graph_mask + if mask is not None: + mask_f = mask.reshape(-1, *([1] * (loss_b.ndim - 1))).to(loss_b.dtype) + per_row = loss_b.numel() // loss_b.shape[0] + denom = (mask.sum() * per_row).clamp(min=1) + self._raw_losses[key] = (loss_b * mask_f).sum() / denom + self._tw_losses[key] = (loss_tw_b * mask_f).sum() / denom + else: + self._raw_losses[key] = loss_b.mean() + self._tw_losses[key] = loss_tw_b.mean() + + def set_losses(self, losses: dict[str, torch.Tensor]): + """Fallback for pre-reduced scalar losses.""" + self._raw_losses = losses + self._tw_losses = losses.copy() + self._current_time_weights.clear() + + def add_stat(self, key: str, value): + """Record a diagnostic value without adding it to the objective.""" + self._stats[key] = value + + def add_stats(self, stats: dict): + """Record diagnostic values without adding them to the objective.""" + self._stats.update(stats) + + # ── Gradient diagnostics ───────────────────────────────────────── + + def compute_grad_norms( + self, + parameters, + *, + granularity: str = "component", + norm_type: float = 2.0, + use_weighted: bool = False, + apply_component_weights: bool = False, + ) -> dict[str, torch.Tensor]: + """Per-loss-term gradient norms w.r.t. ``parameters``. + + For each loss term (or group), computes ``torch.autograd.grad`` against + ``parameters`` with ``retain_graph=True`` so the graph remains intact + for the subsequent ``loss.backward()`` call. The ``.grad`` attribute of + each parameter is *not* mutated. + + Cost scales linearly with the number of terms reported (one extra + backward each), so callers should rate-limit invocation. + + Args: + parameters: Iterable of parameters to differentiate against. + granularity: ``"component"`` for per-key norms, ``"group"`` for + per-group sums of raw losses, or ``"both"``. + norm_type: p-norm used to reduce gradient tensors (default L2). + use_weighted: If ``True``, differentiate the time-weighted loss + ``self._tw_losses[k]``; otherwise use the raw loss + ``self._raw_losses[k]``. + apply_component_weights: If ``True``, multiply each term by its + current component weight (matches what ``total_loss`` actually + backprops). Useful to gauge effective gradient contribution. + + Returns: + Mapping ``{key_or_group: scalar grad-norm tensor}``. Keys for + losses that don't depend on any parameter (or whose graph was + detached) report ``0.0``. + """ + if granularity not in {"component", "group", "both"}: + raise ValueError( + f"granularity must be one of component/group/both, got {granularity!r}" + ) + + params = [p for p in parameters if p.requires_grad] + losses = self._tw_losses if use_weighted else self._raw_losses + + if apply_component_weights: + current_weights = self._weight_module.get_weight_tensors() + losses = { + k: float(current_weights.get(k, 1.0)) * v for k, v in losses.items() + } + + terms: dict[str, torch.Tensor] = {} + if granularity in ("component", "both"): + terms.update(losses) + if granularity in ("group", "both"): + for group, keys in self._groups.items(): + summands = [losses[k] for k in keys if k in losses] + if not summands: + continue + terms[f"__group__{group}"] = sum(summands) + + norms: dict[str, torch.Tensor] = {} + if not params or not terms: + return norms + + zero = next(iter(losses.values())).new_zeros(()) + for key, loss in terms.items(): + out_key = ( + key.removeprefix("__group__") if key.startswith("__group__") else key + ) + if not (isinstance(loss, torch.Tensor) and loss.requires_grad): + norms[out_key] = zero + continue + grads = torch.autograd.grad( + loss, + params, + retain_graph=True, + allow_unused=True, + create_graph=False, + ) + present = [g.detach() for g in grads if g is not None] + if not present: + norms[out_key] = zero + continue + stacked = torch.stack([g.norm(norm_type) ** norm_type for g in present]) + norms[out_key] = stacked.sum() ** (1.0 / norm_type) + + return norms + + # ── Outputs ────────────────────────────────────────────────────── + + def total_loss(self) -> torch.Tensor: + """Apply component weights to time-weighted losses for backpropagation.""" + return self._weight_module(self._tw_losses) + + def log_dict(self) -> dict[str, torch.Tensor | float]: + """Builds a uniform log dict separating pure and fully weighted losses.""" + entries: dict[str, torch.Tensor | float] = {} + + current_weights = self._weight_module.get_weight_tensors() + + total_pure = 0.0 + total_fully_weighted = 0.0 + + # Keep values on-device (detach only); Lightning offloads/reduces them + # lazily. A per-step .cpu() here forces a blocking D2H sync per component. + # 1. Log individual components + for key in self._raw_losses: + pure_val = self._raw_losses[key].detach() + + # Use current_weights here + weight_k = current_weights.get(key, 1.0) + fully_weighted_val = (weight_k * self._tw_losses[key]).detach() + + entries[f"loss/{key}"] = pure_val + entries[f"loss_weighted/{key}"] = fully_weighted_val + + total_pure += pure_val + total_fully_weighted += fully_weighted_val + + # 2. Log group sums + for group, keys in self._groups.items(): + pure_terms = [ + self._raw_losses[k].detach() for k in keys if k in self._raw_losses + ] + weighted_terms = [ + (current_weights.get(k, 1.0) * self._tw_losses[k]).detach() + for k in keys + if k in self._tw_losses + ] + + if pure_terms: + entries[f"loss/{group}"] = sum(pure_terms) + if weighted_terms: + entries[f"loss_weighted/{group}"] = sum(weighted_terms) + + # 3. Log totals + entries["loss/total"] = total_pure + entries["loss_weighted/total"] = total_fully_weighted + + # 4. Log stats, weights, and time-weights + for key, val in self._stats.items(): + entries[f"stats/{key}"] = val + + if getattr(self._weight_module, "use_learnable", False): + for k, w in current_weights.items(): + entries[f"weight/{k}"] = w + + for group, tw_val in self._current_time_weights.items(): + entries[f"time_weight/{group}"] = tw_val + + return entries diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/lr_schedulers.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/lr_schedulers.py new file mode 100644 index 0000000000000000000000000000000000000000..11f5b6ac9551480c8d4b539ba75614f9add1d887 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/lr_schedulers.py @@ -0,0 +1,157 @@ +import math + +from torch.optim.lr_scheduler import _LRScheduler + + +class LinearOneCycleLR(_LRScheduler): + """ + Linearly increase LR from initial_lr to max_lr over warmup_steps, + then keep LR constant at max_lr. + """ + + def __init__(self, optimizer, max_lr, warmup_steps, last_epoch=-1): + self.max_lr = max_lr + self.warmup_steps = warmup_steps + + # Save initial LRs of param groups + self.base_lrs = [pg["lr"] for pg in optimizer.param_groups] + + super().__init__(optimizer, last_epoch) + + def get_lr(self): + """Return each group's rate during warmup or the constant phase.""" + # Step-based schedule + if self.last_epoch >= self.warmup_steps: + # Constant LR phase + return [self.max_lr for _ in self.optimizer.param_groups] + + # Linear warmup phase + warmup_ratio = (self.last_epoch + 1) / self.warmup_steps + return [ + base_lr + warmup_ratio * (self.max_lr - base_lr) + for base_lr in self.base_lrs + ] + + +class CosineWarmupLR(_LRScheduler): + """ + Linearly increases LR from initial_lr to max_lr over warmup_steps, + then decays to min_lr using a cosine schedule over the remaining steps. + """ + + def __init__( + self, optimizer, max_lr, warmup_steps, total_steps, min_lr=1e-6, last_epoch=-1 + ): + self.max_lr = max_lr + self.min_lr = min_lr + self.warmup_steps = warmup_steps + self.total_steps = total_steps + + # Save initial LRs of param groups + self.base_lrs = [pg["lr"] for pg in optimizer.param_groups] + + super().__init__(optimizer, last_epoch) + + def get_lr(self): + """Return each group's rate during warmup or cosine decay.""" + # 1. Linear Warmup Phase + if self.last_epoch < self.warmup_steps: + alpha = self.last_epoch / self.warmup_steps + return [ + base_lr + alpha * (self.max_lr - base_lr) for base_lr in self.base_lrs + ] + + # 2. Cosine Decay Phase + # Calculate progress from 0.0 to 1.0 within the decay phase + progress = (self.last_epoch - self.warmup_steps) / ( + self.total_steps - self.warmup_steps + ) + progress = min(progress, 1.0) # Clip to ensure we don't go past end + + cosine_decay = 0.5 * (1 + math.cos(math.pi * progress)) + + return [ + self.min_lr + (self.max_lr - self.min_lr) * cosine_decay + for _ in self.base_lrs + ] + + +class MultiGroupCosineWarmupLR(_LRScheduler): + """ + Linearly increases LR from 0 to each group's base_lr over warmup_steps, + then decays to a fraction of the base_lr using a cosine schedule. + """ + + def __init__( + self, optimizer, warmup_steps, total_steps, min_lr_fraction=0.1, last_epoch=-1 + ): + self.warmup_steps = warmup_steps + self.total_steps = total_steps + self.min_lr_fraction = ( + min_lr_fraction # e.g., 0.1 means decay to 10% of base_lr + ) + super().__init__(optimizer, last_epoch) + + def get_lr(self): + """Apply warmup or cosine decay to each group's base rate.""" + # 1. Linear Warmup Phase + if self.last_epoch < self.warmup_steps: + alpha = (self.last_epoch + 1) / max(1, self.warmup_steps) + return [base_lr * alpha for base_lr in self.base_lrs] + + # 2. Cosine Decay Phase + progress = (self.last_epoch - self.warmup_steps) / max( + 1, self.total_steps - self.warmup_steps + ) + progress = min(progress, 1.0) # Clip to ensure we don't go past end + + cosine_decay = 0.5 * (1 + math.cos(math.pi * progress)) + + # Calculate the multiplier scale (between min_lr_fraction and 1.0) + multiplier = self.min_lr_fraction + (1.0 - self.min_lr_fraction) * cosine_decay + + return [base_lr * multiplier for base_lr in self.base_lrs] + + +class EMADecayScheduler: + """EMA decay scheduler with hold + fast ramp + slow ramp phases.""" + + def __init__( + self, + initial_decay: float, + mid_decay: float, + final_decay: float, + warmup_epochs: int, + fast_ramp_epochs: int, + total_epochs: int, + ): + self.initial_decay = float(initial_decay) + self.mid_decay = float(mid_decay) + self.final_decay = float(final_decay) + self.warmup_epochs = max(0, int(warmup_epochs)) + self.fast_ramp_epochs = max(0, int(fast_ramp_epochs)) + self.total_epochs = int(total_epochs) + + def get_decay(self, current_epoch: int) -> float: + """Return EMA decay for the given epoch within total training epochs.""" + if self.total_epochs <= 1: + return float(self.final_decay) + + current_epoch = min(max(0, int(current_epoch)), self.total_epochs - 1) + last_epoch = float(self.total_epochs - 1) + warmup = float(min(self.warmup_epochs, self.total_epochs - 1)) + fast_end = float( + min(self.warmup_epochs + self.fast_ramp_epochs, self.total_epochs - 1) + ) + + if current_epoch < warmup: + return float(self.initial_decay) + + if current_epoch < fast_end: + progress = (float(current_epoch) - warmup) / max(fast_end - warmup, 1e-8) + return float( + self.initial_decay + progress * (self.mid_decay - self.initial_decay) + ) + + progress = (float(current_epoch) - fast_end) / max(last_epoch - fast_end, 1e-8) + return float(self.mid_decay + progress * (self.final_decay - self.mid_decay)) diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/DIFFDEC_LICENSE b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/DIFFDEC_LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..847f0cf8f1f5823f9bd17ace360c375600e67080 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/DIFFDEC_LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Biomed-AI@NSCC-GZ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__init__.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..dde8f9096dac04f01b60db32f58a841c72974641 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__init__.py @@ -0,0 +1,175 @@ +"""Molecular generation metrics; upstream attribution is in docs/development.md. + +Package layout (split by dependency): +- ``tensor`` : geometric tensor metrics + bin edges/accumulators (no RDKit) +- ``distribution``: KL distribution metrics, batch-fed (no RDKit) +- ``chemistry`` : RDKit checks, valence-table checks, shared pool, PoseBusters +- ``plotting`` : GT-vs-generated marginal plots + +Public surface re-exported here so ``from morph.utils.metrics import X`` +keeps working unchanged. +""" + +import torch +from torchmetrics import MetricCollection + +from morph.utils.metrics.chemistry import ( + AtomStability, + Diversity, + MoleculeStability, + Novelty, + SanitizationValidity, + Uniqueness, + ValenceTableAtomStability, + ValenceTableMoleculeStability, + Validity, + calc_atom_stabilities, + calc_posebusters_metrics, +) +from morph.utils.metrics.distribution import ( + AtomCountKL, + AtomTypeKL, + ChargeTypeKL, + EdgeTypeKL, +) +from morph.utils.metrics.plotting import build_marginal_plots +from morph.utils.metrics.tensor import build_batch_metrics + +# Public surface consumed elsewhere via ``from morph.utils.metrics import X``. +__all__ = [ + "MetricCollection", + "build_marginal_plots", + "calc_atom_stabilities", + "calc_metrics_", + "init_metrics", +] + + +def init_metrics( + train_smiles: list[str] | None = None, + target_n_atoms_distribution: torch.Tensor | None = None, + atom_type_distribution: torch.Tensor | None = None, + edge_type_distribution: torch.Tensor | None = None, + charge_type_distribution: torch.Tensor | None = None, + atom_tokens: list[str] | None = None, + edge_tokens: list[str] | None = None, + charge_tokens: list[str] | None = None, + allow_charged: bool = False, + distributions=None, + representation=None, + train_smiles_are_canonical: bool = False, +): + # Chemistry metrics consume the molecule population that validation has + # already gathered onto rank zero. Their compute calls must therefore not + # enter TorchMetrics collectives that the other ranks cannot join. + chemistry_metric_kwargs = {"sync_on_compute": False} + metrics = { + "validity": Validity(allow_charged=allow_charged, **chemistry_metric_kwargs), + "connected-sanitization-validity": SanitizationValidity( + **chemistry_metric_kwargs + ), + "valence-table-atom-stability": ValenceTableAtomStability( + **chemistry_metric_kwargs + ), + "valence-table-molecule-stability": ValenceTableMoleculeStability( + **chemistry_metric_kwargs + ), + "uniqueness": Uniqueness(**chemistry_metric_kwargs), + "diversity": Diversity(**chemistry_metric_kwargs), + **( + { + "novelty": Novelty( + train_smiles, + already_canonical=train_smiles_are_canonical, + **chemistry_metric_kwargs, + ) + } + if train_smiles is not None + else {} + ), + } + + # Distribution metrics: tensor-native KLs computed straight from the batch, + # so they run in every representation. Kept in their own collection so they + # accumulate across an entire validation epoch (rather than per batch) and + # drive the ground-truth-vs-generated marginal plots. Edge/charge KLs are + # only registered when the representation actually carries that field — + # otherwise they are degenerate (all / neutral by projection) and + # would log a meaningless 0. + want_edges = representation is None or representation.requires_topology + want_charges = representation is None or representation.requires_charges + distribution_metrics: dict = {} + tensor_metric_kwargs = {"sync_on_compute": False} + if target_n_atoms_distribution is not None: + distribution_metrics["atom_count_dist_kl"] = AtomCountKL( + target_n_atoms_distribution, **tensor_metric_kwargs + ) + if atom_type_distribution is not None and atom_tokens is not None: + distribution_metrics["atom_type_dist_kl"] = AtomTypeKL( + atom_type_distribution, labels=atom_tokens, **tensor_metric_kwargs + ) + if edge_type_distribution is not None and edge_tokens is not None and want_edges: + distribution_metrics["edge_type_dist_kl"] = EdgeTypeKL( + edge_type_distribution, labels=edge_tokens, **tensor_metric_kwargs + ) + if ( + charge_type_distribution is not None + and charge_tokens is not None + and want_charges + ): + distribution_metrics["charge_type_dist_kl"] = ChargeTypeKL( + charge_type_distribution, labels=charge_tokens, **tensor_metric_kwargs + ) + + stability_metrics = { + "rdkit-atom-stability": AtomStability(), + "rdkit-molecule-stability": MoleculeStability(), + } + + metrics = MetricCollection(metrics, compute_groups=False) + stability_metrics = MetricCollection(stability_metrics, compute_groups=False) + distribution_metrics = MetricCollection(distribution_metrics, compute_groups=False) + + # Geometric batch metrics — built from training-set target stats stored in + # Distributions. Run in every representation. The collection may be empty if + # Distributions lacks the geometric target stats. + batch_metrics_dict: dict = {} + if distributions is not None: + batch_metrics_dict = build_batch_metrics(distributions) + batch_metrics = MetricCollection(batch_metrics_dict, compute_groups=False) + + return metrics, stability_metrics, distribution_metrics, batch_metrics + + +def calc_metrics_(rdkit_mols, metrics, *, posebusters_contexts=None) -> dict: + """Run an RDKit-mol metric collection over a list of mols and return the + scalar results. Stability lives in its own collection with a different + input contract (precomputed bools) — drive it separately. Atom-count KL + runs tensor-side as ``AtomCountKL``. + """ + # These molecules are gathered explicitly by validation. Compute each + # metric without torchmetrics' implicit DDP synchronization: list-backed + # states (uniqueness/diversity) cannot be reduced by torchmetrics, and a + # rank-zero-only compute would otherwise deadlock on tensor-backed metrics. + results = {} + for name, metric in metrics.items(): + metric.reset() + metric.update(rdkit_mols) + value = metric.compute() + results[name] = value.item() if isinstance(value, torch.Tensor) else value + try: + kwargs = ( + {"scaffold_contexts": posebusters_contexts} + if posebusters_contexts is not None + else {} + ) + posebusters = calc_posebusters_metrics(rdkit_mols, **kwargs) + except Exception as exc: + # PoseBusters is diagnostic and must not discard the core chemistry + # metrics or abort a long training run if one structural check fails. + print(f"Error calculating PoseBusters metrics: {exc}") + else: + results.update( + {f"posebusters/{name}": value for name, value in posebusters.items()} + ) + return results diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/__init__.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df6792b97cbf1cc139b661b95ee0b57b4edbadb2 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/__init__.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/chemistry.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/chemistry.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82982715157a07a6f12c97c6aa8afe050f01d1f0 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/chemistry.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/distribution.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/distribution.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cbe4c5bf31cee4442a21ec8923e02f786065969c Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/distribution.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/plotting.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/plotting.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb55c9a0e679ab85f814d7f10b9842ccca7534a6 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/plotting.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/scaffold.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/scaffold.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01239a9c84c37cd2f5ffeb73922dcf6e6e07ea30 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/scaffold.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/scaffold_posebusters.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/scaffold_posebusters.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd6255516da4f1211d284261b139b0c646c1fedf Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/scaffold_posebusters.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/tensor.cpython-313.pyc b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/tensor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..001f321720479f44079790237b1a2dfa69c4c901 Binary files /dev/null and b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/__pycache__/tensor.cpython-313.pyc differ diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/chemistry.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/chemistry.py new file mode 100644 index 0000000000000000000000000000000000000000..3f8b6998ed1b6930674c4f977791b4a35a46631f --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/chemistry.py @@ -0,0 +1,937 @@ +"""RDKit-mol metrics + the helpers they need (process pool, explicit valence +tables, PoseBusters). Everything in this file pulls RDKit; the tensor / +distribution side stays RDKit-free. +""" + +import faulthandler +import math +import multiprocessing as mp +import os +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +import pandas as pd +import torch +from rdkit import Chem, DataStructs, RDLogger +from rdkit.Chem import AllChem +from torchmetrics import Metric + +from morph.utils import rdkit_utils as morphRD +from morph.utils.metrics.scaffold_posebusters import scaffold_posebusters_metrics + +faulthandler.enable() +# Silence RDKit warnings (in addition to morph.utils.rdkit_utils) because +# this module may be imported first from some code paths. +RDLogger.DisableLog("rdApp.*") + + +# --------------------------------------------------------------------------- +# Base class +# --------------------------------------------------------------------------- + + +class GenerativeMetric(Metric): + """torchmetrics base for metrics consuming a list of RDKit mols.""" + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate statistics from a batch of RDKit molecules.""" + raise NotImplementedError + + def compute(self) -> torch.Tensor: + """Reduce the accumulated molecular statistics to a metric value.""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Shared process pool for CPU-heavy RDKit work (optimise_mol / calc_energy). +# +# Why this exists: +# * MMFF optimisation + energy calc is Python/C serial per-molecule. +# * Running it on the main thread inside validation_step starves the +# TorchElastic rendezvous keep-alive thread and triggers +# RendezvousTimeoutError on long val cycles. +# * We use a "spawn" context so the children do not inherit the parent's +# CUDA context (fork + CUDA = undefined behavior). +# * The pool is lazy so simply importing this module does not pay the +# spawn cost; it starts on the first validation step. +# --------------------------------------------------------------------------- + +_RD_POOL: ProcessPoolExecutor | None = None +_RD_POOL_MAX_WORKERS = int(os.environ.get("MORPH_RD_POOL_WORKERS", "4")) +_PB_POOL: ProcessPoolExecutor | None = None +_PB_POOL_WORKERS = 0 + + +def _get_rdkit_pool() -> ProcessPoolExecutor: + global _RD_POOL # noqa: PLW0603 -- one reusable process-local worker pool + if _RD_POOL is None: + affinity = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 + ) + # Leave at least half the cores for DataLoader workers + rendezvous. + n = max(1, min(_RD_POOL_MAX_WORKERS, affinity // 2)) + _RD_POOL = ProcessPoolExecutor( + max_workers=n, + mp_context=mp.get_context("spawn"), + ) + return _RD_POOL + + +def _get_posebusters_pool(max_workers: int) -> ProcessPoolExecutor: + """Return a persistent spawn-safe pool for chunked PoseBusters checks.""" + global _PB_POOL, _PB_POOL_WORKERS # noqa: PLW0603 + if _PB_POOL is None or max_workers != _PB_POOL_WORKERS: + if _PB_POOL is not None: + _PB_POOL.shutdown(wait=True) + _PB_POOL = ProcessPoolExecutor( + max_workers=max_workers, + mp_context=mp.get_context("spawn"), + ) + _PB_POOL_WORKERS = max_workers + return _PB_POOL + + +def _pool_map(fn, items: list) -> list: + """Run ``fn`` over ``items`` in the shared RDKit pool. Falls back to a + plain list comprehension if the pool cannot be constructed (e.g. spawn is + unavailable) so metrics never break training. + """ + if not items: + return [] + try: + pool = _get_rdkit_pool() + n_workers = pool._max_workers # type: ignore[attr-defined] + chunk = max(1, len(items) // (n_workers * 4)) + return list(pool.map(fn, items, chunksize=chunk)) + except Exception: + return [fn(x) for x in items] + + +def _rd_optimise_mol(mol): + return morphRD.optimise_mol(mol) + + +# Validation runs MMFF optimisation up to 4x over an identical batch +# (opt-energy-validity + strain + strain-per-atom + opt-rmsd), each calling +# ``_pool_map(_rd_optimise_mol, ...)`` on the same ordered non-None mols. +# ``calc_metrics_`` feeds the *same* list object to every metric in the +# collection, so memoize one shared optimise pass per batch keyed on the list +# identity. A reference to ``mols`` is held while cached so its ``id`` cannot be +# recycled (size-1 cache); a different list object recomputes. Correctness does +# not depend on the cache hitting -- a miss simply recomputes. ``optimise_mol`` +# copies its input and the consumers (energy, conf_distance) are read-only, so +# sharing the optimised mols across metrics is safe. +_OPT_CACHE: dict = {"mols": None, "optimised": None} +_SMILES_CACHE: dict = {"mols": None, "smiles": None} + + +def _shared_optimise(mols: list) -> list: + """MMFF-optimise ``[m for m in mols if m is not None]`` once per batch, + shared across every optimise-using metric. Returns the optimised mols + aligned to that non-None subset (in original order; ``None`` where + optimisation failed). + """ + if _OPT_CACHE["mols"] is not mols: + non_none = [m for m in mols if m is not None] + _OPT_CACHE["mols"] = mols + _OPT_CACHE["optimised"] = _pool_map(_rd_optimise_mol, non_none) + return _OPT_CACHE["optimised"] + + +def _shared_canonical_smiles(mols: list) -> list[str | None]: + """Canonicalise one generated population once for uniqueness and novelty.""" + if _SMILES_CACHE["mols"] is not mols: + _SMILES_CACHE["mols"] = mols + _SMILES_CACHE["smiles"] = [ + None if mol is None else morphRD.smiles_from_mol(mol, canonical=True) + for mol in mols + ] + return _SMILES_CACHE["smiles"] + + +def _rd_calc_energy(mol): + return morphRD.calc_energy(mol) + + +def _rd_calc_energy_per_atom(mol): + return morphRD.calc_energy(mol, per_atom=True) + + +def _is_valid_float(num): + return num is not None and math.isfinite(float(num)) + + +def _canonical_smiles(smi: str) -> str | None: + mol = Chem.MolFromSmiles(smi) + if mol is None: + return None + return Chem.MolToSmiles(mol, canonical=True) + + +# --------------------------------------------------------------------------- +# Stability helpers +# --------------------------------------------------------------------------- + + +def calc_atom_stabilities(mol): + """Per-atom RDKit-sanity flags. ``True`` means *stable* (no problem + detected), ``False`` means the atom is involved in a + ``DetectChemistryProblems`` finding. Aligned so that downstream metrics + named ``*_stability`` report a *higher = better* number. + """ + # Diagnose the raw graph on a copy: sanitization would discard the very + # valence/kekulization failures this metric is meant to count. + mol = Chem.Mol(mol) + stabilities = [True] * mol.GetNumAtoms() + for p in Chem.DetectChemistryProblems(mol): + if hasattr(p, "GetAtomIdx"): + stabilities[p.GetAtomIdx()] = False + elif hasattr(p, "GetAtomIndices"): + for index in p.GetAtomIndices(): + stabilities[index] = False + else: + raise ValueError(f"Cannot assign RDKit problem to atoms: {p.Message()}") + return stabilities + + +ALLOWED_VALENCES_BY_ELEMENT = { + "H": {0: 1, 1: 0, -1: 0}, + "C": {0: [3, 4], 1: 3, -1: 3}, + # QM9 contains N+ in forms such as NH+ and NH2+. + "N": {0: [2, 3], 1: [2, 3, 4], -1: 2}, + "O": {0: 2, 1: 3, -1: 1}, + "F": {0: 1, -1: 0}, + "B": 3, + "Al": 3, + "Si": 4, + "P": {0: [3, 5], 1: 4}, + "S": {0: [2, 6], 1: [2, 3], 2: 4, 3: 5, -1: 3}, + "Cl": 1, + "As": 3, + "Br": {0: 1, 1: 2}, + "I": 1, + "Hg": [1, 2], + "Bi": [3, 5], + "Se": [2, 4, 6], +} + + +def _matches_allowed_valence(valence, allowed, charge): + if isinstance(allowed, int): + return allowed == valence + if isinstance(allowed, list): + return valence in allowed + if isinstance(allowed, dict): + sub = allowed.get(charge) + if sub is None: + return False + return _matches_allowed_valence(valence, sub, charge) + return False + + +def mol_passes_sanitization( + mol: Chem.rdchem.Mol, with_hs: bool = True, connected: bool = True +) -> bool: + """Check sanitisation and optional connectivity, allowing charges and radicals.""" + if mol is None: + return False + mol_copy = Chem.Mol(mol) + if not with_hs: + mol_copy = Chem.RemoveAllHs(mol_copy) + try: + AllChem.SanitizeMol(mol_copy) + except Exception: + return False + return not (connected and len(AllChem.GetMolFrags(mol_copy)) != 1) + + +def calc_valence_table_stabilities(mol): + """Check each atom's total valence against the element/formal-charge table. + + Total valence includes implicit hydrogens. Unknown elements or charge states + absent from an element's charge-specific rules fail. This is independent of + RDKit's chemistry-problem flags and imposes no separate radical check. + """ + stabilities = [] + for atom in mol.GetAtoms(): + sym = atom.GetSymbol() + if sym not in ALLOWED_VALENCES_BY_ELEMENT: + stabilities.append(False) + continue + stabilities.append( + _matches_allowed_valence( + atom.GetTotalValence(), + ALLOWED_VALENCES_BY_ELEMENT[sym], + atom.GetFormalCharge(), + ) + ) + return stabilities + + +# --------------------------------------------------------------------------- +# PoseBusters +# --------------------------------------------------------------------------- + + +def _posebusters_config() -> dict: + """The reliable stock ``mol`` checks without energy/strain or flatness. + + Upstream ``config/mol.yml`` includes an ``energy_ratio`` entry without + ``chosen_binary_test_output``. It contributes no column when + ``full_report=False`` but can still execute, so outputless modules are + removed together with every energy-ratio entry. + + Energy relaxation dominates runtime and is deliberately outside this + structural plausibility check. PoseBusters' RDKit flatness implementation + can segfault on malformed generated structures; one such child terminates + the process pool and discards every metric. Keep the robust cheap checks + (sanity, connectivity, radicals, and distance geometry) in routine + validation. Energy/strain and flatness can be run as separate, explicitly + requested analyses on a prevalidated molecule set. + """ + # PoseBusters stays out of the ordinary metric import path. + import posebusters # noqa: PLC0415 + from yaml import safe_load # noqa: PLC0415 + + cfg_path = Path(posebusters.__file__).parent / "config" / "mol.yml" + cfg = safe_load(cfg_path.read_text(encoding="utf-8")) + + # We always call `bust(..., full_report=False)`, which returns only the + # columns named in `chosen_binary_test_output`. A module without that key + # therefore produces nothing while still running — drop it. Remove all + # explicitly unsupported modules in the second filter. + modules = [m for m in cfg.get("modules", []) if m.get("chosen_binary_test_output")] + modules = [ + m for m in modules if m.get("function") not in {"energy_ratio", "flatness"} + ] + cfg["modules"] = modules + return cfg + + +def _bust_posebusters_chunk(mols: list[Chem.rdchem.Mol]) -> pd.DataFrame: + """Run PoseBusters serially on one worker-owned molecule chunk.""" + from posebusters import PoseBusters # noqa: PLC0415 + + return PoseBusters(config=_posebusters_config(), max_workers=0).bust( + mols, None, None + ) + + +def calc_posebusters_metrics( + rdkit_mols: list[Chem.rdchem.Mol | None], + *, + max_workers: int | None = None, + scaffold_contexts: list[dict | None] | None = None, +) -> dict[str, float]: + """PoseBusters plausibility checks, reported with explicit denominators. + + ``rdkit_mols`` is the FULL generated list, ``None`` where the molecule could + not be built/sanitised. PoseBusters can only run on the non-``None`` subset, + so every rate has one of two bases and we report both plus their counts: + + * ``*_over_busted`` — denominator is the molecules PoseBusters evaluated + (the sanitisable subset). This is what the stock implementation reported, + and what Morph's published per-check table is on. + * ``*_over_generated`` — denominator is ``len(rdkit_mols)``, with + unsanitisable molecules counted as failures. This is the convention + TABASCO uses ("we save all outputs from the model, as opposed to only + valid molecules"). + + ``all_pass`` implements the headline definition — *a molecule is only + PoseBusters-valid if it passes every test*. A check that could not be + evaluated (NaN from an unavailable structural check, for example) counts + as a FAILURE, never as a pass: ``bool(float("nan"))`` is ``True`` in Python, + so a naive ``df.all(axis=1)`` would silently mark those molecules valid. + ``n_incomplete`` and the per-check ``*__n_nan`` keys expose exactly how many + molecules that affects, and ``all_pass_complete_only`` gives the rate over + molecules where every check did evaluate. + + ``max_workers=None`` (the default) uses up to eight allocation-bound CPU + workers. PoseBusters' own parallel path pickles its complete input dataframe + once for every molecule, which is quadratic in the population size. Instead, + each spawned worker receives one disjoint chunk and runs it in-process. + Pass ``0`` or a negative value to force in-process execution. + """ + n_generated = len(rdkit_mols) + if scaffold_contexts is not None and len(scaffold_contexts) != n_generated: + raise ValueError("Scaffold contexts must align with every generated attempt") + valid_mols = [mol for mol in rdkit_mols if mol is not None] + valid_contexts = ( + [ + ctx + for mol, ctx in zip(rdkit_mols, scaffold_contexts, strict=True) + if mol is not None + ] + if scaffold_contexts is not None + else None + ) + n_busted = len(valid_mols) + if not valid_mols: + strict_validity = 0.0 if n_generated else float("nan") + results = { + "validity": strict_validity, + "n_generated": float(n_generated), + "n_busted": 0.0, + "n_all_pass": 0.0, + "all_pass_over_generated": strict_validity, + } + if valid_contexts is not None: + results.update( + scaffold_posebusters_metrics(pd.DataFrame(), [], [], n_generated, {}) + ) + return results + + if max_workers is None: + affinity = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 + ) + max_workers = max(1, min(8, affinity)) + # Keep the heavy optional dependency and its CUDA probing out of ordinary + # training startup. The local config always excludes energy/strain. + from posebusters import PoseBusters # noqa: PLC0415 + + if max_workers > 0 and PoseBusters.__module__.startswith("posebusters."): + chunk_size = math.ceil(n_busted / max_workers) + chunks = [ + valid_mols[start : start + chunk_size] + for start in range(0, n_busted, chunk_size) + ] + frames = list( + _get_posebusters_pool(max_workers).map(_bust_posebusters_chunk, chunks) + ) + df = pd.concat(frames, ignore_index=True) + else: + df = PoseBusters(config=_posebusters_config(), max_workers=max_workers).bust( + valid_mols, None, None + ) + + results: dict[str, float] = { + "n_generated": float(n_generated), + "n_busted": float(n_busted), + } + + # Per-check rates. `pd.to_numeric` maps True/False -> 1.0/0.0 and leaves + # un-evaluated entries as NaN; `.mean()` skips NaN, so we record the + # evaluated count alongside every rate rather than hiding the shrinkage. + check_cols: list[str] = [] + for col in df.columns: + if col in ("file", "molecule"): + continue + try: + numeric = pd.to_numeric(df[col], errors="coerce") + except Exception: + print(f"Warning: could not process PoseBusters column '{col}'.") + continue + check_cols.append(col) + n_nan = int(numeric.isna().sum()) + n_eval = n_busted - n_nan + results[col] = float(numeric.mean()) + results[f"{col}__n_eval"] = float(n_eval) + results[f"{col}__n_nan"] = float(n_nan) + # Same check, but every un-evaluated molecule counts as a failure and + # the denominator is every generated molecule. + results[f"{col}__over_generated"] = float( + numeric.fillna(0.0).sum() / n_generated + ) + + checks = df[check_cols].apply(pd.to_numeric, errors="coerce") + if valid_contexts is not None: + geometry = next( + m + for m in _posebusters_config()["modules"] + if m["function"] == "distance_geometry" + ) + results.update( + scaffold_posebusters_metrics( + checks, + valid_mols, + valid_contexts, + n_generated, + geometry.get("parameters", {}), + ) + ) + + if not check_cols: + results["n_all_pass"] = 0.0 + results["all_pass_over_generated"] = 0.0 + results["validity"] = 0.0 + return results + + evaluated = checks.notna().all(axis=1) + passed = checks.fillna(0.0).astype(bool).all(axis=1) # NaN -> failure + n_all_pass = int(passed.sum()) + n_incomplete = int((~evaluated).sum()) + + results["n_checks"] = float(len(check_cols)) + results["n_all_pass"] = float(n_all_pass) + results["n_incomplete"] = float(n_incomplete) + results["all_pass_over_busted"] = n_all_pass / n_busted + results["all_pass_over_generated"] = n_all_pass / n_generated + # Canonical aggregate: every enabled check must pass, and failed molecule + # construction is a failure because the denominator is all generations. + results["validity"] = results["all_pass_over_generated"] + # Rate over molecules where every check actually evaluated, for comparison + # against the strict number above. + n_complete = int(evaluated.sum()) + results["n_complete"] = float(n_complete) + results["all_pass_complete_only"] = ( + float((passed & evaluated).sum()) / n_complete if n_complete else float("nan") + ) + return results + + +# --------------------------------------------------------------------------- +# RDKit-stability metrics (fed precomputed bools) +# --------------------------------------------------------------------------- + + +class AtomStability(Metric): + """Fraction of atoms passing RDKit ``DetectChemistryProblems``. Fed + precomputed per-atom bools by ``calc_atom_stabilities`` (True = stable). + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("atom_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, stabilities: list[list[bool]]) -> None: + """Accumulate per-atom stability flags across molecules.""" + flat = [b for mol in stabilities for b in mol] + self.atom_stable += sum(flat) + self.total += len(flat) + + def compute(self) -> torch.Tensor: + """Return the fraction of evaluated atoms that are stable.""" + return self.atom_stable.float() / self.total + + +class MoleculeStability(Metric): + """Fraction of molecules where every atom is stable.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("mol_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, stabilities: list[list[bool]]) -> None: + """Count molecules whose atoms are all stable.""" + mol_stables = [len(mol) > 0 and all(mol) for mol in stabilities] + self.mol_stable += sum(mol_stables) + self.total += len(mol_stables) + + def compute(self) -> torch.Tensor: + """Return the fraction of evaluated molecules that are stable.""" + return self.mol_stable.float() / self.total + + +# --------------------------------------------------------------------------- +# Sanitisation validity and explicit valence-table checks (RDKit-mol input) +# --------------------------------------------------------------------------- + + +class SanitizationValidity(GenerativeMetric): + """Sanitisable, by default connected molecules divided by all attempts. + + Applies no additional charge or radical filter. ``Validity`` also rejects + radicals and, unless configured otherwise, nonzero net formal charge. + """ + + def __init__(self, with_hs: bool = True, connected: bool = True, **kwargs): + super().__init__(**kwargs) + self.with_hs = with_hs + self.connected = connected + self.add_state("valid", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count molecules passing sanitisation and the connectivity policy.""" + is_valid = [ + mol_passes_sanitization(mol, with_hs=self.with_hs, connected=self.connected) + for mol in mols + ] + self.valid += sum(is_valid) + self.total += len(mols) + + def compute(self) -> torch.Tensor: + """Return valid molecules divided by all attempted molecules.""" + return self.valid.float() / self.total + + +class ValenceTableAtomStability(GenerativeMetric): + """Atoms matching the element/charge valence table divided by evaluable atoms. + + Unbuildable molecules and failed valence evaluations are excluded. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("atom_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate valence-based atom stability for evaluable molecules.""" + for mol in mols: + if mol is None: + continue + try: + stabilities = calc_valence_table_stabilities(mol) + except Exception: + continue + self.atom_stable += sum(stabilities) + self.total += len(stabilities) + + def compute(self) -> torch.Tensor: + """Return stable atoms divided by all evaluable atoms.""" + return self.atom_stable.float() / self.total + + +class ValenceTableMoleculeStability(GenerativeMetric): + """All atoms matching the element/charge valence table, over all attempts. + + Empty molecules, unbuildable molecules and failed evaluations count as failures. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("mol_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count fully stable molecules, retaining failures in the denominator.""" + for mol in mols: + if mol is None: + self.total += 1 + continue + try: + stabilities = calc_valence_table_stabilities(mol) + except Exception: + self.total += 1 + continue + if len(stabilities) > 0 and all(stabilities): + self.mol_stable += 1 + self.total += 1 + + def compute(self) -> torch.Tensor: + """Return stable molecules divided by all attempted molecules.""" + return self.mol_stable.float() / self.total + + +# --------------------------------------------------------------------------- +# Validity / uniqueness / novelty +# --------------------------------------------------------------------------- + + +class Validity(GenerativeMetric): + """Fraction of attempted molecules passing Morph's chemistry checks.""" + + def __init__(self, allow_charged: bool = False, **kwargs): + super().__init__(**kwargs) + self.allow_charged = allow_charged + self.add_state("valid", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count valid molecules, including failed decodes in the denominator.""" + is_valid = [ + morphRD.mol_is_valid(mol, allow_charged=self.allow_charged) + for mol in mols + if mol is not None + ] + self.valid += sum(is_valid) + self.total += len(mols) + + def compute(self) -> torch.Tensor: + """Return valid molecules divided by all attempted molecules.""" + return self.valid.float() / self.total + + +# TODO I don't think this will work with DDP +class Uniqueness(GenerativeMetric): + """Note: only tracks uniqueness of molecules which can be converted into SMILES""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.valid_smiles = [] + + def reset(self): + """Clear accumulated canonical SMILES and metric state.""" + super().reset() + self.valid_smiles = [] + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Collect canonical SMILES for successfully converted molecules.""" + # Use the same canonicalizer as Novelty (RemoveHs + canonical MolToSmiles + # with exception-safe None fallback) so the two metrics share a SMILES + # space and comparable denominators. + smiles = _shared_canonical_smiles(mols) + self.valid_smiles.extend(smi for smi in smiles if smi is not None) + + def compute(self) -> torch.Tensor: + """Return distinct canonical SMILES divided by valid SMILES count.""" + if not self.valid_smiles: + return torch.tensor(float("nan")) + return torch.tensor(len(set(self.valid_smiles))) / len(self.valid_smiles) + + +class Diversity(GenerativeMetric): + """Mean pairwise Tanimoto *distance* over ECFP fingerprints. Higher = more diverse. + + Matches TABASCO's ``MolecularDiversity``, which calls ``datamol.pdist`` -> + ``scipy.spatial.distance.pdist(metric="jaccard")``. Two details are needed + for their number to be comparable: datamol's ECFP default is **radius 3** + (ECFP6), not RDKit's usual radius 2, and the reported quantity is a + *distance* (1 - similarity), so their 0.89 means mean similarity ~0.11. + + Population is every molecule accumulated since ``reset()``, not a per-batch + average: pairs are formed across the whole set in ``compute()``. + """ + + def __init__(self, radius: int = 3, n_bits: int = 2048, **kwargs): + super().__init__(**kwargs) + self.radius = radius + self.n_bits = n_bits + self.fps = [] + + def reset(self): + """Clear accumulated molecular fingerprints and metric state.""" + super().reset() + self.fps = [] + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Collect Morgan fingerprints for successfully converted molecules.""" + for mol in mols: + if mol is None: + continue + try: + self.fps.append( + AllChem.GetMorganFingerprintAsBitVect( + mol, self.radius, nBits=self.n_bits + ) + ) + except Exception: + continue + + def compute(self) -> torch.Tensor: + """Return mean pairwise Tanimoto distance, or NaN for fewer than two.""" + if len(self.fps) < 2: + return torch.tensor(float("nan")) + # Sum over the upper triangle only; BulkTanimotoSimilarity is the C++ + # bulk path, so this stays O(n^2) comparisons without n^2 Python calls. + total, n_pairs = 0.0, 0 + for i, fp in enumerate(self.fps[:-1]): + sims = DataStructs.BulkTanimotoSimilarity(fp, self.fps[i + 1 :]) + total += len(sims) - sum(sims) # distance = 1 - similarity + n_pairs += len(sims) + return torch.tensor(total / n_pairs) + + +class Novelty(GenerativeMetric): + """Fraction of canonical generated SMILES absent from the training set.""" + + def __init__( + self, + train_smiles: list[str], + already_canonical: bool = False, + **kwargs, + ): + super().__init__(**kwargs) + + if already_canonical: + canonical = train_smiles + else: + affinity = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 + ) + n_workers = min(8, affinity) + with ProcessPoolExecutor(max_workers=n_workers) as executor: + canonical = list(executor.map(_canonical_smiles, train_smiles)) + + self.smiles = {smi for smi in canonical if smi is not None} + + self.add_state("novel", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count novel SMILES among successfully canonicalized molecules.""" + smiles = _shared_canonical_smiles(mols) + valid_smiles = [smi for smi in smiles if smi is not None] + novel = [smi not in self.smiles for smi in valid_smiles] + + self.novel += sum(novel) + self.total += len(novel) + + def compute(self) -> torch.Tensor: + """Return novel molecules divided by canonicalized molecule count.""" + return self.novel.float() / self.total + + +# --------------------------------------------------------------------------- +# Energy / strain / RMSD +# --------------------------------------------------------------------------- + + +class EnergyValidity(GenerativeMetric): + """Fraction of attempted molecules with a finite force-field energy.""" + + def __init__(self, optimise=False, **kwargs): + super().__init__(**kwargs) + self.optimise = optimise + self.add_state("n_valid", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count finite energies, optionally after geometry optimization.""" + num_mols = len(mols) + non_none = [m for m in mols if m is not None] + if self.optimise: + non_none = [m for m in _shared_optimise(mols) if m is not None] + energies = _pool_map(_rd_calc_energy, non_none) + valid_energies = [e for e in energies if _is_valid_float(e)] + self.n_valid += len(valid_energies) + self.total += num_mols + + def compute(self) -> torch.Tensor: + """Return finite-energy molecules divided by all attempted molecules.""" + return self.n_valid.float() / self.total + + +class AverageEnergy(GenerativeMetric): + """Average energy for molecules for which energy can be calculated. + + Energy can't be calculated for invalid mols and pose optimisation isn't + guaranteed to succeed; failures don't count towards the metric. Does not + require sanitisation, but sanitising first is usually a good idea. + """ + + def __init__(self, optimise=False, per_atom=False, **kwargs): + super().__init__(**kwargs) + self.optimise = optimise + self.per_atom = per_atom + self.add_state("energy", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state( + "n_valid_energies", default=torch.tensor(0), dist_reduce_fx="sum" + ) + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate finite molecular or per-atom force-field energies.""" + non_none = [m for m in mols if m is not None] + if self.optimise: + non_none = [m for m in _shared_optimise(mols) if m is not None] + energy_fn = _rd_calc_energy_per_atom if self.per_atom else _rd_calc_energy + energies = _pool_map(energy_fn, non_none) + valid_energies = [e for e in energies if _is_valid_float(e)] + self.energy += sum(valid_energies) + self.n_valid_energies += len(valid_energies) + + def compute(self) -> torch.Tensor: + """Return mean energy over successfully evaluated molecules.""" + return self.energy / self.n_valid_energies + + +# TODO: Add xTB as level of theory option and add forces as a metric + + +class AverageStrainEnergy(GenerativeMetric): + """Energy difference between a molecule's pose and its optimised pose. + + Only counted when the molecule has an energy, pose optimisation succeeds, + and the optimised-pose energy is calculable. Combine with + ``EnergyValidity(optimise=True)`` to track the fraction of molecules this + metric can be calculated on. + """ + + def __init__(self, per_atom=False, **kwargs): + super().__init__(**kwargs) + self.per_atom = per_atom + self.add_state( + "total_energy_diff", default=torch.tensor(0.0), dist_reduce_fx="sum" + ) + self.add_state("n_valid", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate energy drops between input and optimized geometries.""" + indexed = [(idx, m) for idx, m in enumerate(mols) if m is not None] + if not indexed: + return + + indices, _ = zip(*indexed, strict=True) + optimised = _shared_optimise(mols) + + energy_fn = _rd_calc_energy_per_atom if self.per_atom else _rd_calc_energy + + opt_pairs = [ + (i, m) for i, m in zip(indices, optimised, strict=True) if m is not None + ] + if not opt_pairs: + return + opt_indices, opt_mols_list = zip(*opt_pairs, strict=True) + opt_energies = _pool_map(energy_fn, list(opt_mols_list)) + + pairs = [ + (i, e) + for i, e in zip(opt_indices, opt_energies, strict=True) + if e is not None + ] + if not pairs: + return + + valid_indices, valid_energies = zip(*pairs, strict=True) + original_energies = _pool_map(energy_fn, [mols[i] for i in valid_indices]) + + energy_diffs = [ + orig - opt + for orig, opt in zip(original_energies, valid_energies, strict=True) + ] + self.total_energy_diff += sum(energy_diffs) + self.n_valid += len(energy_diffs) + + def compute(self) -> torch.Tensor: + """Return mean energy drop for pairs with valid energies.""" + return self.total_energy_diff / self.n_valid + + +class AverageOptRmsd(GenerativeMetric): + """Average RMSD between a molecule and its optimised pose. + + Only counted when the molecule is valid and pose optimisation succeeds. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("total_rmsd", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state("n_valid", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate alignment RMSD between input and optimized geometries.""" + indexed = [(idx, m) for idx, m in enumerate(mols) if m is not None] + if not indexed: + return + + indices, _ = zip(*indexed, strict=True) + optimised = _shared_optimise(mols) + + pairs = [ + (i, m) for i, m in zip(indices, optimised, strict=True) if m is not None + ] + if not pairs: + return + + valid_indices, opt_mols = zip(*pairs, strict=True) + original_mols = [mols[i] for i in valid_indices] + rmsds = [ + morphRD.conf_distance(m1, m2) + for m1, m2 in zip(original_mols, opt_mols, strict=True) + ] + self.total_rmsd += sum(rmsds) + self.n_valid += len(rmsds) + + def compute(self) -> torch.Tensor: + """Return mean RMSD over successfully evaluated molecule pairs.""" + return self.total_rmsd / self.n_valid diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/denovo.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/denovo.py new file mode 100644 index 0000000000000000000000000000000000000000..0efb61baf81f07f66e30e9f1981bba66cfd08cb8 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/denovo.py @@ -0,0 +1,676 @@ +"""SDF-based de novo evaluation adapted from Morph's eval_sdf_metrics.py. + +Retains the original chemistry, MMFF94 and stock PoseBusters ``mol`` coverage. +Chemistry checks use this repository's corrected stability flags and hydrogen +policy. Failed records stay in rate denominators; finite-only averages expose +their sample counts. No reference dataset, checkpoint or GPU is required. +""" + +from __future__ import annotations + +import hashlib +import math +import multiprocessing as mp +import pickle +from concurrent.futures import ProcessPoolExecutor, as_completed +from functools import partial +from importlib.metadata import version +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pandas as pd +from rdkit import Chem, rdBase +from rdkit.Chem import AllChem + +from morph.utils import rdkit_utils as morphRD +from morph.utils.metrics.chemistry import ( + Diversity, + SanitizationValidity, + ValenceTableAtomStability, + ValenceTableMoleculeStability, + Validity, + calc_atom_stabilities, +) +from morph.utils.metrics.posebusters_energy import EnergyRatioWorker + +if TYPE_CHECKING: + from collections.abc import Collection + + +def _validate_stereochemistry(stereochemistry: str) -> None: + if stereochemistry not in {"ignore", "retain"}: + raise ValueError("smiles_stereochemistry must be 'ignore' or 'retain'") + + +def _identity_smiles(mol, *, stereochemistry: str) -> str | None: + if mol is None: + return None + identity = Chem.Mol(mol) + if stereochemistry == "ignore": + # SDF loading can infer stereo from coordinates that was never present + # on training-time generated graphs. Strip it on an identity-only copy; + # geometry checks must still see the original molecule and stereotags. + # Unlike isomericSmiles=False, this preserves isotope identities. + Chem.RemoveStereochemistry(identity) + return morphRD.smiles_from_mol(identity, canonical=True) + + +def canonicalize_reference_smiles( + smiles: Collection[str], *, smiles_stereochemistry: str = "ignore" +) -> set[str]: + """Normalize a training reference in the generated molecules' SMILES space. + + Remove hydrogens and, by default, stereochemistry to match Morph's + generated graphs. Use the same stereo policy for evaluation. Reject invalid + entries instead of silently inflating novelty. + """ + _validate_stereochemistry(smiles_stereochemistry) + canonical = set() + for index, value in enumerate(smiles): + mol = Chem.MolFromSmiles(value) + smi = _identity_smiles(mol, stereochemistry=smiles_stereochemistry) + if not smi: + raise ValueError( + f"Invalid reference SMILES at entry {index + 1}: {value!r}" + ) + canonical.add(smi) + if not canonical: + raise ValueError("The novelty reference must contain at least one molecule") + return canonical + + +def load_sdf( + path: str | Path, *, sanitize: bool = True, remove_hs: bool = False +) -> list[Chem.Mol | None]: + """Load SDF records in order, retaining RDKit failures as ``None``. + + Hydrogens and their implicit/explicit policy are preserved by default. + ``remove_hs`` removes explicit H atoms after successful sanitization. + Empty files yield no records; missing or unreadable files raise an error. + """ + path = Path(path) + if path.stat().st_size == 0: + return [] + # list(SDMolSupplier(...)) asks for a length hint, which first indexes the + # file. RDKit 2025.09.6 can misplace record offsets at 64 KiB boundaries, + # prepend a newline, and reject a perfectly valid record's counts line. + # The forward reader avoids that indexing pass and preserves properties. + with path.open("rb") as handle: + mols = list( + Chem.ForwardSDMolSupplier(handle, sanitize=sanitize, removeHs=False) + ) + for index, mol in enumerate(mols): + if mol is None: + continue + try: + # Raw loading still needs a property cache for total-valence checks. + mol.UpdatePropertyCache(strict=False) + if remove_hs: + mols[index] = Chem.RemoveHs(mol) + except Exception: + mols[index] = None + return mols + + +def _finite(value): + return value is not None and math.isfinite(value) + + +def _energy_record(mol, *, add_hs, max_iters): + values = dict.fromkeys( + ["energy", "energy-per-atom", "strain", "strain-per-atom", "opt-rmsd"] + ) + values.update({"opt-energy": None, "optimization_status": None}) + if mol.GetNumAtoms() == 0 or mol.GetNumConformers() == 0: + return values + try: + original = Chem.Mol(mol) + if add_hs: + original = Chem.AddHs(original, addCoords=True) + n_atoms = original.GetNumAtoms() + energy = morphRD.calc_energy(original) + if _finite(energy): + values["energy"] = energy + values["energy-per-atom"] = energy / n_atoms + optimized = Chem.Mol(original) + status = AllChem.MMFFOptimizeMolecule( + optimized, mmffVariant="MMFF94", maxIters=max_iters + ) + values["optimization_status"] = status + if status < 0: + return values + opt_energy = morphRD.calc_energy(optimized) + if _finite(opt_energy): + values["opt-energy"] = opt_energy + if _finite(energy): + values["strain"] = energy - opt_energy + values["strain-per-atom"] = (energy - opt_energy) / n_atoms + rmsd = morphRD.conf_distance(original, optimized) + if _finite(rmsd): + values["opt-rmsd"] = float(rmsd) + except Exception: + # Unsupported chemistry/poses are counted, never removed from rates. + # Retain an input energy even if subsequent optimization failed. + pass + return values + + +def _call_named_chunk(function, chunk, names): + # RDKit's default multiprocessing pickle omits molecular properties, + # including _Name. Preserve IDs explicitly for per-molecule diagnostics. + for mol, name in zip(chunk, names, strict=True): + if name is not None: + mol.SetProp("_Name", name) + return function(chunk) + + +def _map_chunks(function, chunks, workers, *, cache_dir=None): + results, pending = [None] * len(chunks), [] + cache_paths = {} + if cache_dir is not None: + cache_dir = Path(cache_dir) + cache_dir.mkdir(parents=True, exist_ok=True) + source = ( + Path(__file__).read_bytes() + + Path(__file__).with_name("posebusters_energy.py").read_bytes() + + Path(morphRD.__file__).read_bytes() + ) + for index, chunk in enumerate(chunks): + payload = ( + function, + rdBase.rdkitVersion, + version("posebusters"), + source, + [m.ToBinary(Chem.PropertyPickleOptions.AllProps) for m in chunk], + ) + digest = hashlib.sha256(pickle.dumps(payload)).hexdigest() + path = cache_dir / f"{index:05d}-{digest}.pkl" + cache_paths[index] = path + if path.exists(): + results[index] = pickle.loads(path.read_bytes()) + else: + pending.append(index) + else: + pending = list(range(len(chunks))) + stage = getattr(function, "func", function).__name__ + completed = len(chunks) - len(pending) + print(f"{stage}: {completed}/{len(chunks)} chunks restored", flush=True) + + def record(index, result): + nonlocal completed + results[index] = result + if index in cache_paths: + path = cache_paths[index] + temporary = path.with_suffix(".pending") + temporary.write_bytes(pickle.dumps(result)) + temporary.replace(path) + completed += 1 + print(f"{stage}: {completed}/{len(chunks)} chunks complete", flush=True) + + if workers == 1: + for index in pending: + record(index, function(chunks[index])) + return results + with ProcessPoolExecutor( + max_workers=workers, mp_context=mp.get_context("spawn") + ) as executor: + futures = { + executor.submit( + _call_named_chunk, + function, + chunks[i], + [m.GetProp("_Name") if m.HasProp("_Name") else None for m in chunks[i]], + ): i + for i in pending + } + for future in as_completed(futures): + record(futures[future], future.result()) + return results + + +def _energy_chunk(mols, *, add_hs, max_iters): + return [_energy_record(mol, add_hs=add_hs, max_iters=max_iters) for mol in mols] + + +def _chunks(mols, workers): + size = max(1, math.ceil(len(mols) / (workers * 4))) + return [mols[start : start + size] for start in range(0, len(mols), size)] + + +def _energy_metrics(mols, *, workers, add_hs, max_iters, cache_dir=None): + chunks = _chunks([m for m in mols if m is not None], workers) + function = partial(_energy_chunk, add_hs=add_hs, max_iters=max_iters) + records = [ + r + for batch in _map_chunks(function, chunks, workers, cache_dir=cache_dir) + for r in batch + ] + results, counts = {}, {} + for key in ( + "energy", + "energy-per-atom", + "strain", + "strain-per-atom", + "opt-rmsd", + "opt-energy", + ): + values = [r[key] for r in records if _finite(r[key])] + results[key] = math.fsum(values) / len(values) if values else float("nan") + counts[key] = len(values) + results["energy-validity"] = counts["energy"] / len(mols) + results["opt-energy-validity"] = counts["opt-energy"] / len(mols) + counts["optimization-converged"] = sum( + r["optimization_status"] == 0 for r in records + ) + counts["optimization-not-converged"] = sum( + r["optimization_status"] == 1 for r in records + ) + counts["optimization-failed"] = ( + len(mols) + - counts["optimization-converged"] + - counts["optimization-not-converged"] + ) + return results, counts + + +def _flatness_coordinates(mol, indices): + """Read coordinates without NumPy converting RDKit Point3D objects. + + PoseBusters 0.6.5's Point3D conversion can segfault with the installed + RDKit/Python/NumPy stack. GetPositions returns the same coordinates as a + numeric array, preserving atom order and the original flatness calculation. + """ + return mol.GetConformer().GetPositions()[list(indices)] + + +def _posebusters_config(): + from posebusters import PoseBusters # noqa: PLC0415 + + config = PoseBusters(config="mol", max_workers=0).config + # The stock config repeats the same energy calculation, first without any + # selected output. Failed ensembles aren't cached by PoseBusters, causing + # an expensive second attempt. Keep the identical scored entry once. + config["modules"] = [ + module + for module in config["modules"] + if module["function"] != "energy_ratio" + or module.get("chosen_binary_test_output") + ] + for module in config["modules"]: + if module["function"] == "energy_ratio": + # Parallelism is across molecules. RDKit's default num_threads=0 + # would let every worker launch an additional machine-wide pool. + module.setdefault("parameters", {})["num_threads"] = 1 + return config + + +def _posebusters_chunk(mols, *, energy_timeout=180): + from posebusters import PoseBusters # noqa: PLC0415 + + # Match the original offline evaluator, including flatness and energy. + # Chunk outside PoseBusters to avoid repeatedly pickling the entire set. + with ( + EnergyRatioWorker(energy_timeout) as energy, + patch("posebusters.modules.flatness._get_coords", _flatness_coordinates), + patch.dict( + "posebusters.posebusters.module_dict", {"energy_ratio": energy.check} + ), + ): + frame = PoseBusters(config=_posebusters_config(), max_workers=0).bust( + mols, None, None + ) + frame.attrs["energy_timeout_names"] = energy.timed_out_names + return frame + + +def _posebusters_checks(frame): + return frame.drop(columns=["file", "molecule"], errors="ignore").apply( + pd.to_numeric, errors="coerce" + ) + + +def _posebusters_passes(frame): + checks = _posebusters_checks(frame) + if not len(checks.columns): + return [False] * len(frame) + return checks.eq(1).all(axis=1).tolist() + + +def _summarize_posebusters(frame, n_attempts, n_busted): + if len(frame) != n_busted: + raise ValueError("PoseBusters must return one row per supplied molecule") + results = {"n_generated": n_attempts, "n_busted": n_busted} + checks = _posebusters_checks(frame) + for name in checks: + column = checks[name] + results[name] = float(column.mean()) + results[f"{name}__n_eval"] = int(column.notna().sum()) + results[f"{name}__n_nan"] = int(column.isna().sum()) + results[f"{name}__over_generated"] = float(column.fillna(0).sum()) / n_attempts + if len(checks.columns): + passed = checks.eq(1).all(axis=1) + n_complete = int(checks.notna().all(axis=1).sum()) + n_passed = int(passed.sum()) + else: + n_complete, n_passed = 0, 0 + results.update( + { + "n_checks": len(checks.columns), + "n_complete": n_complete, + "n_incomplete": n_busted - n_complete, + "n_all_pass": n_passed, + "all_pass_over_generated": n_passed / n_attempts, + "all_pass_over_busted": (n_passed / n_busted if n_busted else float("nan")), + "validity": n_passed / n_attempts, + } + ) + return results + + +def _combined_metrics( + mols, + *, + allow_charged, + reference_smiles, + pb_passes, + smiles_stereochemistry="ignore", +): + """Intersect per-molecule predicates, then deduplicate each passing subset.""" + if pb_passes is not None and len(pb_passes) != len(mols): + raise ValueError("PoseBusters flags must align with every attempted molecule") + records = [] + for index, mol in enumerate(mols): + smi = _identity_smiles(mol, stereochemistry=smiles_stereochemistry) + records.append( + { + "input_index": index, + "name": mol.GetProp("_Name") + if mol is not None and mol.HasProp("_Name") + else None, + "canonical_smiles": smi, + "valid": bool(morphRD.mol_is_valid(mol, allow_charged=allow_charged)), + "novel": smi not in reference_smiles + if reference_smiles is not None and smi is not None + else None, + "posebusters_valid": bool(pb_passes[index]) + if pb_passes is not None + else None, + } + ) + valid = [row for row in records if row["valid"]] + valid_smiles = [ + row["canonical_smiles"] for row in valid if row["canonical_smiles"] is not None + ] + convertible = [row for row in records if row["canonical_smiles"] is not None] + all_smiles = [row["canonical_smiles"] for row in convertible] + counts = { + "n_smiles": len(all_smiles), + "n_valid": len(valid), + "n_valid_smiles": len(valid_smiles), + } + numerators = {"valid-unique": len(set(valid_smiles))} + results = { + "uniqueness": len(set(all_smiles)) / len(all_smiles) + if all_smiles + else float("nan") + } + if reference_smiles is not None: + novel = [row for row in convertible if row["novel"]] + valid_novel = [row for row in valid if row["novel"]] + counts.update( + {"n_novel_smiles": len(novel), "n_reference_smiles": len(reference_smiles)} + ) + results["novelty"] = ( + len(novel) / len(convertible) if convertible else float("nan") + ) + results["novelty-among-valid"] = ( + len(valid_novel) / len(valid_smiles) if valid_smiles else float("nan") + ) + numerators["valid-novel"] = len(valid_novel) + numerators["valid-unique-novel"] = len( + {row["canonical_smiles"] for row in valid_novel} + ) + if pb_passes is not None: + valid_pb = [row for row in valid if row["posebusters_valid"]] + numerators["valid-posebusters"] = len(valid_pb) + if reference_smiles is not None: + numerators["valid-unique-novel-posebusters"] = len( + {row["canonical_smiles"] for row in valid_pb if row["novel"]} + ) + results.update({name: count / len(mols) for name, count in numerators.items()}) + counts["combined_numerators"] = numerators + return results, counts, records + + +def summarize_rdkit_stability(stabilities: list[list[bool] | None]) -> dict: + """Summarize raw-graph flags without hiding attempts with unknown atoms. + + ``None`` means the raw graph could not be evaluated. In that case the + overall atom denominator is unknown, so atom stability is undefined. + Molecule stability counts such attempts as failures. This is a chemistry + problem check, not a geometric, radical, or connectivity check. + """ + if not stabilities: + raise ValueError("No attempted molecules to evaluate") + known = [flags for flags in stabilities if flags is not None] + n_atoms = sum(map(len, known)) + n_stable = sum(sum(flags) for flags in known) + missing = len(stabilities) - len(known) + return { + "results": { + "rdkit-atom-stability": ( + n_stable / n_atoms if n_atoms and not missing else float("nan") + ), + "rdkit-molecule-stability": ( + sum(bool(flags) and all(flags) for flags in known) / len(stabilities) + ), + }, + "counts": { + "n_rdkit_stability_atoms": n_atoms, + "n_rdkit_stable_atoms": n_stable, + "n_rdkit_unstable_atoms": n_atoms - n_stable, + "n_rdkit_stability_molecules": sum(bool(flags) for flags in known), + "n_rdkit_stability_missing_graphs": missing, + }, + } + + +def compute_denovo_metrics( + mols: list[Chem.Mol | None], + *, + rdkit_stability_mols: list[Chem.Mol | None] | None = None, + allow_charged: bool = False, + run_energy: bool = True, + run_posebusters: bool = True, + run_diversity: bool = True, + workers: int = 1, + energy_hydrogens: str = "preserve", + max_iters: int = 1000, + reference_smiles: Collection[str] | None = None, + smiles_stereochemistry: str = "ignore", + posebusters_energy_timeout: float = 180, + checkpoint_dir: str | Path | None = None, +) -> dict: + """Evaluate the de novo SDF metric stack from the original Morph repo. + + ``mols`` must include every attempted output, with ``None`` for failures. + Rates are fractions; energies use kcal/mol and RMSD uses Angstrom. Undefined + averages are NaN. Counts expose the populations used by averages. + Supply unsanitized graphs for every attempt in ``rdkit_stability_mols``. + Otherwise stability checks use copies of ``mols`` before other metrics. + Missing graphs make atom stability undefined rather than survivor-biased. + + MMFF optimizes one copy per molecule, retaining finite nonconverged results + as the original evaluator did, but recording convergence separately. + ``energy_hydrogens='add'`` adds H coordinates on an energy-only copy; the + default ``'preserve'`` uses exactly the SDF atoms, matching the original. + All atoms in that copy enter per-atom normalization and alignment RMSD. + + PoseBusters uses the full installed ``mol`` config, unlike the cheaper + training checks. Exceptions propagate instead of silently dropping checks. + Diversity is the repository's ECFP6 pairwise Tanimoto distance (O(N squared)). + Novelty requires a training reference normalized with + ``canonicalize_reference_smiles`` with the same ``smiles_stereochemistry``. + Identity ignores stereo by default, matching Morph's generated graphs; + ``'retain'`` distinguishes stereoisomers. Neither policy changes geometry. + Combined rates use every attempt as their + denominator, deduplicating only after applying all requested predicates. + For a molecular identity with several poses, any passing pose qualifies. + """ + if not mols: + raise ValueError("No attempted molecules to evaluate") + stability_inputs = mols if rdkit_stability_mols is None else rdkit_stability_mols + if len(stability_inputs) != len(mols): + raise ValueError("rdkit_stability_mols must contain every attempt") + stabilities = [] + for mol in stability_inputs: + try: + stabilities.append(calc_atom_stabilities(mol) if mol is not None else None) + except Exception: + stabilities.append(None) + stability = summarize_rdkit_stability(stabilities) + if workers < 1 or max_iters < 1: + raise ValueError("workers and max_iters must be positive") + if posebusters_energy_timeout <= 0: + raise ValueError("posebusters_energy_timeout must be positive") + checkpoint_dir = Path(checkpoint_dir) if checkpoint_dir is not None else None + if energy_hydrogens not in {"preserve", "add"}: + raise ValueError("energy_hydrogens must be 'preserve' or 'add'") + _validate_stereochemistry(smiles_stereochemistry) + if reference_smiles is not None: + reference_smiles = set(reference_smiles) + if not reference_smiles: + raise ValueError("The novelty reference must contain at least one molecule") + + metric_types = { + "validity": partial(Validity, allow_charged=allow_charged), + "connected-sanitization-validity": SanitizationValidity, + "valence-table-atom-stability": ValenceTableAtomStability, + "valence-table-molecule-stability": ValenceTableMoleculeStability, + } + if run_diversity: + metric_types["diversity"] = Diversity + results, counts = {}, {"n_attempts": len(mols)} + for name, metric_type in metric_types.items(): + metric = metric_type(sync_on_compute=False) + metric.update(mols) + results[name] = float(metric.compute()) + if name == "diversity": + counts["n_fingerprints"] = len(metric.fps) + elif name == "valence-table-atom-stability": + counts["n_valence_table_atoms"] = int(metric.total) + + results.update(stability["results"]) + counts.update(stability["counts"]) + + if run_energy: + energies, energy_counts = _energy_metrics( + mols, + workers=workers, + add_hs=energy_hydrogens == "add", + max_iters=max_iters, + cache_dir=checkpoint_dir / "energy" if checkpoint_dir is not None else None, + ) + results.update(energies) + counts["energy"] = energy_counts + + pb_config = None + pb_passes = None + if run_posebusters: + pb_config = _posebusters_config() + non_none = [m for m in mols if m is not None] + function = partial( + _posebusters_chunk, energy_timeout=posebusters_energy_timeout + ) + # Small work units prevent a few hard conformers from monopolizing + # the last worker and provide frequent durable checkpoints. + pb_chunks = [ + chunk[start : start + 32] + for chunk in _chunks(non_none, workers) + for start in range(0, len(chunk), 32) + ] + frames = _map_chunks( + function, + pb_chunks, + workers, + cache_dir=checkpoint_dir / "posebusters" + if checkpoint_dir is not None + else None, + ) + counts["posebusters_energy_timeout_names"] = [ + name + for frame in frames + for name in frame.attrs.get("energy_timeout_names", []) + ] + counts["n_posebusters_energy_timeouts"] = len( + counts["posebusters_energy_timeout_names"] + ) + frame = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() + results.update( + { + f"posebusters/{key}": value + for key, value in _summarize_posebusters( + frame, len(mols), len(non_none) + ).items() + } + ) + # Preserve correspondence through failed/omitted records. Pool mapping + # and concatenation retain the original non-None molecule order. + non_none_passes = iter(_posebusters_passes(frame)) + pb_passes = [ + next(non_none_passes) if mol is not None else False for mol in mols + ] + combined, combined_counts, records = _combined_metrics( + mols, + allow_charged=allow_charged, + reference_smiles=reference_smiles, + pb_passes=pb_passes, + smiles_stereochemistry=smiles_stereochemistry, + ) + results.update(combined) + counts.update(combined_counts) + return { + "protocol": "morph_denovo_sdf", + "results": results, + "counts": counts, + "molecule_results": records, + "settings": { + "rdkit_stability_input": ( + "separate_unsanitized_graphs" + if rdkit_stability_mols is not None + else "provided_molecules" + ), + "rdkit_stability_missing_graph_policy": "atom_rate_undefined", + "allow_charged": allow_charged, + "run_energy": run_energy, + "energy_hydrogens": energy_hydrogens, + "force_field": "MMFF94", + "max_iters": max_iters, + "run_posebusters": run_posebusters, + "posebusters_energy_timeout_seconds": posebusters_energy_timeout, + "checkpoint_dir": str(checkpoint_dir) + if checkpoint_dir is not None + else None, + "posebusters_config": pb_config, + "posebusters_flatness_coordinates": ( + "rdkit_getpositions" if run_posebusters else None + ), + "run_diversity": run_diversity, + "novelty_reference_provided": reference_smiles is not None, + "smiles_stereochemistry": smiles_stereochemistry, + "smiles_identity": ( + "canonical, H-stripped, stereochemistry " + + ("ignored" if smiles_stereochemistry == "ignore" else "retained") + ), + "combined_denominator": "all_attempts", + "combined_uniqueness": ( + "distinct SMILES after filtering; any qualifying pose counts" + ), + "workers": workers, + }, + "versions": { + "rdkit": rdBase.rdkitVersion, + "posebusters": version("posebusters") if run_posebusters else None, + }, + } diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/diffdec.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/diffdec.py new file mode 100644 index 0000000000000000000000000000000000000000..9d76733cb3131f206e75df820b26274253559919 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/diffdec.py @@ -0,0 +1,186 @@ +"""Non-docking evaluation functions used in DiffDec (Xie et al., JCIM 2024). + +Ported from ``biomed-AI/DiffDec/compute_metrics.py`` at revision +``916ae14207b2783a90336bb8509374535c5791f9``: +https://github.com/biomed-AI/DiffDec/blob/916ae14207b2783a90336bb8509374535c5791f9/compute_metrics.py + +These reproduce the released evaluator, including its fixed uniqueness +denominator, raw SMILES grouping, and omission of all-invalid groups from +uniqueness/similarity. They must not be substituted for Morph's chemistry +validity or pairwise diversity. See ``DIFFDEC_LICENSE`` for upstream licensing. + +Two explicit boundary extensions: empty input raises ValueError; when *every* +attempt is invalid, uniqueness and similarity are NaN (upstream divides by +zero). Otherwise failures are propagated, never silently filtered out. +""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path + +from rdkit import Chem, DataStructs + +DIFFDEC_REVISION = "916ae14207b2783a90336bb8509374535c5791f9" +DIFFDEC_SAMPLES_PER_CONDITION = 100 +_FIELDS = ( + "scaffold", + "true_molecule", + "pred_molecule", + "pred_rgroup", + "protein_filename", +) + + +def _parse(smiles): + mol = Chem.MolFromSmiles(smiles) + return mol if mol is not None else Chem.MolFromSmiles(smiles, sanitize=False) + + +def diffdec_is_valid(pred_smiles: str, scaffold_smiles: str) -> bool: + """Return validity as used in DiffDec, including unsanitized parsing. + + The prediction must be nonempty, differ textually from the scaffold, and + contain it as a substructure. Connectivity, radicals, valence, coordinates, + and stereochemical agreement are not additional requirements here. + """ + if pred_smiles == "" or pred_smiles == scaffold_smiles: + return False + pred = _parse(pred_smiles) + scaffold = _parse(scaffold_smiles) + if scaffold is None: + raise ValueError(f"Cannot parse reference scaffold: {scaffold_smiles!r}") + if pred is None: + return False + return len(pred.GetSubstructMatch(scaffold)) == scaffold.GetNumAtoms() + + +def diffdec_similarity(pred_smiles: str, true_smiles: str) -> float: + """Return reference-ligand similarity as used in DiffDec. + + This is Tanimoto similarity of RDKit path fingerprints (RDKFingerprint), + with the released defaults, not Morgan fingerprints or pairwise diversity. + Call only for predictions accepted by ``diffdec_is_valid``. + """ + return DataStructs.FingerprintSimilarity( + Chem.RDKFingerprint(_parse(pred_smiles)), + Chem.RDKFingerprint(_parse(true_smiles)), + ) + + +def diffdec_recovery_smiles(smiles: str) -> str: + """Return the hydrogen/stereo-stripped recovery key used in DiffDec. + + Retry the complete operation without sanitization if the first attempt + fails, as in the released evaluator. Uniqueness does not use this key. + """ + try: + mol = Chem.MolFromSmiles(smiles) + Chem.RemoveStereochemistry(mol) + return Chem.MolToSmiles(Chem.RemoveHs(mol)) + except Exception: + mol = Chem.MolFromSmiles(smiles, sanitize=False) + Chem.RemoveStereochemistry(mol) + return Chem.MolToSmiles(Chem.RemoveHs(mol, sanitize=False)) + + +def _mean(values): + values = list(values) + return sum(values) / len(values) if values else float("nan") + + +def compute_diffdec_metrics(records) -> dict[str, float]: + """Compute the four non-docking metrics used in DiffDec, exactly as released. + + Records use keys ``scaffold``, ``true_molecule``, ``pred_molecule``, and + ``protein_filename``; ``pred_rgroup`` is optional and unused by the scores. + Every attempted generation must be present, including failures (empty + prediction). Input records are not modified or canonicalized. + + * Validity: mean success fraction per (protein, raw scaffold SMILES), then + mean across that protein's groups, then mean across proteins. + * Uniqueness: distinct *raw* valid prediction SMILES / **100**, per group; + then the same nested averaging, omitting groups/proteins with no valid + predictions. Multiple benchmark instances sharing a group are merged but + still divided by 100; this upstream convention is deliberately preserved. + * Similarity: mean reference similarity over valid attempts **per protein**, + then mean across proteins with valid outputs (no scaffold-level mean). + * Recovery: fraction of distinct canonical, stereo/H-stripped scaffolds + per protein with any valid prediction matching its reference after the + same normalization, then mean across proteins, including failures. + + Validity, uniqueness and recovery are percentages; similarity is in [0, 1]. + Use 100 attempts per benchmark instance; the denominator is not configurable. + """ + records = list(records) + if not records: + raise ValueError("DiffDec evaluation requires at least one attempt") + valid_groups = defaultdict(list) + unique_groups = defaultdict(set) + similarities = defaultdict(list) + seen_scaffolds = defaultdict(set) + recovered_scaffolds = defaultdict(set) + for row in records: + protein, scaffold = row["protein_filename"], row["scaffold"] + pred, true = row["pred_molecule"], row["true_molecule"] + key = (protein, scaffold) + valid = diffdec_is_valid(pred, scaffold) + valid_groups[key].append(valid) + scaffold_key = diffdec_recovery_smiles(scaffold) + seen_scaffolds[protein].add(scaffold_key) + if valid: + unique_groups[key].add(pred) + similarities[protein].append(diffdec_similarity(pred, true)) + if diffdec_recovery_smiles(pred) == diffdec_recovery_smiles(true): + recovered_scaffolds[protein].add(scaffold_key) + + validity_by_protein = defaultdict(list) + uniqueness_by_protein = defaultdict(list) + for (protein, _), values in valid_groups.items(): + validity_by_protein[protein].append(_mean(values)) + for (protein, _), values in unique_groups.items(): + uniqueness_by_protein[protein].append( + len(values) / DIFFDEC_SAMPLES_PER_CONDITION + ) + return { + "validity": 100 * _mean(map(_mean, validity_by_protein.values())), + "uniqueness": 100 * _mean(map(_mean, uniqueness_by_protein.values())), + "similarity": _mean(map(_mean, similarities.values())), + "recovery": 100 + * _mean( + len(recovered_scaffolds[protein]) / len(scaffolds) + for protein, scaffolds in seen_scaffolds.items() + ), + } + + +def read_diffdec_records(path) -> list[dict[str, str]]: + """Read the four/five-column metric .smi format used in DiffDec. + + Literal spaces delimit fields: adjacent spaces encode an empty prediction + or R-group. Generic whitespace splitting would silently shift those fields. + """ + records = [] + with Path(path).open() as handle: + for lineno, line in enumerate(handle, 1): + fields = line.strip().split(" ") + if len(fields) == 4: + fields.insert(3, "") + if len(fields) != 5 or not all(fields[i] for i in (0, 1, 4)): + raise ValueError(f"{path}:{lineno}: expected DiffDec metric fields") + records.append(dict(zip(_FIELDS, fields, strict=True))) + return records + + +def write_diffdec_records(records, path) -> None: + """Write all attempts in the five-column metric .smi format used in DiffDec.""" + with Path(path).open("w") as handle: + for row in records: + fields = [row.get(key, "") for key in _FIELDS] + if not all(fields[i] for i in (0, 1, 4)) or any( + any(char.isspace() for char in value) for value in fields + ): + raise ValueError( + "DiffDec fields need nonempty references and no spaces" + ) + handle.write(" ".join(fields) + "\n") diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/distribution.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/distribution.py new file mode 100644 index 0000000000000000000000000000000000000000..e7928186f4eac061c0d58f59a7da4db6102af192 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/distribution.py @@ -0,0 +1,97 @@ +"""KL(gen || target) distribution metrics, computed directly from the batch. + +All four are marginal over every generated sample (no RDKit, no validity +conditioning) so they run in every representation that carries the field. +They share one attribute contract — ``gen_hist`` / ``target`` / ``labels`` — +so ``plotting.build_marginal_plots`` can render any of them generically. They +live in their own epoch-accumulated collection (see ``init_metrics``). +""" + +import torch + +from morph.dataset.core.atomic_data import ChemBatch +from morph.utils.metrics.tensor import BatchMetric + + +def _kl(p: torch.Tensor, q: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: + p = p / p.sum().clamp(min=eps) + q = q / q.sum().clamp(min=eps) + return (p * (p.clamp(min=eps).log() - q.clamp(min=eps).log())).sum() + + +class _HistogramKL(BatchMetric): + """Shared machinery: a fixed-length generated histogram vs a target one. + + Subclasses implement ``_indices(batch) -> LongTensor`` returning the token + indices to bin for one update; everything else (binning, KL, plotting + attributes) is common. + """ + + def __init__( + self, + target_distribution: torch.Tensor, + labels: list[str] | None = None, + eps: float = 1e-8, + **kwargs, + ): + super().__init__(**kwargs) + target = target_distribution.detach().to(torch.float32) + self.eps = eps + self.K = int(target.numel()) + self.labels = list(labels) if labels is not None else None + self.register_buffer("target", target / target.sum().clamp(min=eps)) + self.add_state( + "gen_hist", + default=torch.zeros(self.K, dtype=torch.float32), + dist_reduce_fx="sum", + ) + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + raise NotImplementedError + + def update(self, batch: ChemBatch) -> None: + idx = self._indices(batch) + if idx.numel() == 0: + return + idx = idx.clamp(0, self.K - 1) + self.gen_hist += torch.bincount(idx, minlength=self.K).to(torch.float32) + + def compute(self) -> torch.Tensor: + if self.gen_hist.sum() <= 0: + return torch.tensor(0.0, device=self.gen_hist.device) + return _kl(self.gen_hist, self.target, self.eps) + + +class AtomCountKL(_HistogramKL): + """KL over per-molecule atom counts.""" + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + return batch.num_nodes_per_graph + + +class AtomTypeKL(_HistogramKL): + """KL over the atom-type histogram.""" + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + return batch.a + + +class ChargeTypeKL(_HistogramKL): + """KL over the formal-charge-token histogram.""" + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + return batch.c + + +class EdgeTypeKL(_HistogramKL): + """KL over upper-triangular pair edge types (incl. ```` at index 0). + + Mirrors the training-side target built in preprocessing: for every molecule + take the dense adjacency and read its strict upper triangle, so non-bonded + pairs contribute ````. PyG batches lay out each graph's nodes + contiguously, so a graph's local indices are ``global - first_node``. + """ + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + rows, cols = batch.edge_index + return batch.e[rows < cols] diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/plotting.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..42f09ef1f616be57c9267af13e0a0b28cb607fb2 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/plotting.py @@ -0,0 +1,135 @@ +# Plotting imports stay lazy; select the headless backend before importing pyplot. +# ruff: noqa: PLC0415 + +"""Ground-truth vs generated marginal plots for wandb logging.""" + +import torch +from torchmetrics import MetricCollection + +from morph.utils.metrics.tensor import RG_RANGE + + +def plot_marginal_comparison( + gen_hist: torch.Tensor, + target_hist: torch.Tensor, + labels: list[str] | None, + title: str, + xlabel: str, + eps: float = 1e-8, +): + """Compare ground-truth and generated marginal densities in a Figure. + + Both inputs are unnormalized histograms; they are normalized to densities here. + """ + import matplotlib as mpl + + mpl.use("Agg", force=False) + import matplotlib.pyplot as plt + import numpy as np + + gen = gen_hist.detach().to("cpu", dtype=torch.float32) + target = target_hist.detach().to("cpu", dtype=torch.float32) + + n = max(gen.numel(), target.numel()) + if gen.numel() < n: + gen = torch.cat([gen, torch.zeros(n - gen.numel())]) + if target.numel() < n: + target = torch.cat([target, torch.zeros(n - target.numel())]) + + gen_sum = float(gen.sum()) + target_sum = float(target.sum()) + gen = gen / max(gen_sum, eps) + target = target / max(target_sum, eps) + + if labels is None or len(labels) != n: + labels = [str(i) for i in range(n)] + + x = np.arange(n) + width = 0.4 + + fig, ax = plt.subplots(figsize=(max(6.0, n * 0.35), 4.0)) + ax.bar( + x - width / 2.0, + target.numpy(), + width, + label="ground truth", + color="steelblue", + alpha=0.85, + ) + ax.bar( + x + width / 2.0, + gen.numpy(), + width, + label="generated", + color="darkorange", + alpha=0.85, + ) + ax.set_xticks(x) + ax.set_xticklabels(labels, rotation=45 if n > 10 else 0, ha="right") + ax.set_ylabel("probability") + ax.set_xlabel(xlabel) + ax.set_title(title) + ax.legend() + fig.tight_layout() + return fig + + +# Per-distribution-metric plot captions, keyed by the collection key set in +# ``init_metrics``. Anything not listed is skipped (no caption == not plotted). +_DIST_PLOT_META = { + "atom_count_dist_kl": ("Number of atoms: ground truth vs generated", "n_atoms"), + "atom_type_dist_kl": ("Atom types: ground truth vs generated", "atom type"), + "edge_type_dist_kl": ( + "Edge types (upper-tri pairs incl. NO_BOND): ground truth vs generated", + "edge type", + ), + "charge_type_dist_kl": ( + "Formal charges: ground truth vs generated", + "formal charge", + ), +} + + +def build_marginal_plots(distribution_metrics: MetricCollection) -> dict: + """One Figure per distribution metric with samples, keyed by a short name. + + Generic over the shared ``gen_hist`` / ``target`` / ``labels`` attribute + contract of the tensor distribution metrics. Metrics that have not seen any + samples are skipped. Caller closes the figures after logging. + """ + plots: dict = {} + for key, (title, xlabel) in _DIST_PLOT_META.items(): + if key not in distribution_metrics: + continue + m = distribution_metrics[key] + if float(m.gen_hist.sum()) <= 0.0: + continue + plots[key.replace("_dist_kl", "")] = plot_marginal_comparison( + gen_hist=m.gen_hist, + target_hist=m.target, + labels=m.labels, + title=title, + xlabel=xlabel, + ) + return plots + + +def build_batch_marginal_plots(metrics, atom_tokens: list[str] | None = None): + """GT-vs-generated plot for the geometric batch metrics that carry a + histogram. Returns ``{wandb_key: matplotlib.Figure}``. + + Currently just radius of gyration — the 1D atom-count / atom-type marginals + moved to the distribution collection (see ``build_marginal_plots``). + """ + figs: dict = {} + by_name = dict(metrics.items()) if hasattr(metrics, "items") else {} + m = by_name.get("rog_l1") + if m is not None and getattr(m, "gen_hist", None) is not None: + figs["val/batch/plots/rog"] = plot_marginal_comparison( + m.gen_hist, + m.target_hist, + None, + "Radius of gyration", + f"RoG bin (Å in [{RG_RANGE[0]}, {RG_RANGE[1]}])", + ) + return figs diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/posebusters_energy.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/posebusters_energy.py new file mode 100644 index 0000000000000000000000000000000000000000..d9a879aa04fe29b422bf9e29bdacc2ffab7b9aa7 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/posebusters_energy.py @@ -0,0 +1,96 @@ +"""Isolate PoseBusters' native energy calculation behind a wall-time limit.""" + +from __future__ import annotations + +import multiprocessing as mp + + +def _serve_energy(connection): + from posebusters.modules.energy_ratio import check_energy_ratio # noqa: PLC0415 + + connection.send("ready") + while True: + request = connection.recv() + if request is None: + break + mol, parameters = request + connection.send(check_energy_ratio(mol, **parameters)) + connection.close() + + +class EnergyRatioWorker: + """Persistent subprocess; a timeout kills native code and resets the worker. + + A Python signal/future timeout cannot interrupt an RDKit C++ call reliably. + Keep non-energy geometry checks in the caller so an energy timeout does not + discard their results. The stock energy function and thresholds are used. + """ + + def __init__(self, timeout: float, *, _target=_serve_energy): + if timeout <= 0: + raise ValueError("Energy timeout must be positive") + self.timeout = timeout + self.target = _target + self.process = None + self.connection = None + self.timed_out_names = [] + + def _start(self): + context = mp.get_context("spawn") + self.connection, child_connection = context.Pipe() + self.process = context.Process(target=self.target, args=(child_connection,)) + self.process.start() + child_connection.close() + # Imports/startup have their own allowance; they do not consume the + # molecule's energy budget or become a false chemistry failure. + if not self.connection.poll(120) or self.connection.recv() != "ready": + self.close() + raise RuntimeError("PoseBusters energy worker failed to start") + + def check(self, mol_pred, **parameters): + """Return the stock energy result, or missing values on timeout.""" + if self.process is None: + self._start() + self.connection.send((mol_pred, parameters)) + if self.connection.poll(self.timeout): + return self.connection.recv() + name = mol_pred.GetProp("_Name") if mol_pred.HasProp("_Name") else None + self.timed_out_names.append(name) + print(f"PoseBusters energy timed out after {self.timeout}s: {name}", flush=True) + self.close() + # Match stock PoseBusters' missing-energy result. Missing checks fail + # joint validity and remain in all-attempt denominators. + return { + "results": dict.fromkeys( + ( + "num_h_added", + "mol_pred_energy", + "ensemble_avg_energy", + "energy_ratio", + "energy_ratio_passes", + ), + float("nan"), + ) + } + + def close(self): + """Stop the subprocess and release its pipe, including after a timeout.""" + if self.process is not None: + self.process.terminate() + self.process.join(timeout=5) + if self.process.is_alive(): + self.process.kill() + self.process.join(timeout=5) + self.process.close() + self.process = None + if self.connection is not None: + self.connection.close() + self.connection = None + + def __enter__(self): + """Start lazily on the first energy check.""" + return self + + def __exit__(self, *_args): + """Always release the worker, including when another PB check raises.""" + self.close() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/posecheck_strain.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/posecheck_strain.py new file mode 100644 index 0000000000000000000000000000000000000000..f92638f7b6ac11d4d3f84cae9f7e012413537d4e --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/posecheck_strain.py @@ -0,0 +1,170 @@ +"""PoseCheck strain energy (Harris et al., 2023). + +This is the metric TABASCO's "Strain Energy" column reports, and it is *not* +what ``AverageStrainEnergy`` in ``chemistry.py`` computes. The two differ in +both terms of the difference: + + this module (PoseCheck) AverageStrainEnergy + force field UFF MMFF94 + minuend pose after a POSITION- raw pose, unrelaxed + CONSTRAINED relaxation + (maxDispl 0.1 A, k = 1e5) + subtrahend min over 50 freshly embedded global minimum of the + + UFF-relaxed conformers, *given* pose only + plus the pose relaxed freely + +Both differences push the same way: PoseCheck's constrained relaxation removes +local geometric noise so the number reflects *conformational* strain rather +than bad bond lengths, while its global reference is <= the nearest local +minimum. So the two metrics are not on a common scale and must not be compared. + +There is a third difference that dwarfs both, in the AGGREGATION: + + TABASCO reports the MEDIAN. ``AverageStrainEnergy`` reports the MEAN. + +TABASCO's ``src/tabasco/utils/metrics.py`` calls PoseCheck's +``calculate_strain_energy`` and aggregates with ``mode`` defaulting to +``"median"``. The per-molecule distribution has a brutal right tail -- on a +6-molecule smoke test the mean was 20.19 against a median of 1.09 -- so the two +statistics differ by more than an order of magnitude and the choice must be +stated with any published number. This module therefore defaults to the median, +matching the table it exists to be compared against. + +``AverageStrainEnergy`` in ``chemistry.py`` follows the upstream MMFF94 metric +with mean aggregation; see ``docs/development.md`` for its attribution. + +References: + - https://github.com/cch1999/posecheck ``posecheck/utils/strain.py`` + - https://github.com/carlosinator/tabasco ``src/tabasco/utils/metrics.py`` + +Cost is ~0.9 s/molecule serial (51 UFF relaxations), ~0.15 s/molecule across the +shared pool. Evaluation-time only -- it is too expensive for a per-step training +objective without a cheaper surrogate. +""" + +from __future__ import annotations + +import torch +from rdkit import Chem +from rdkit.Chem import AllChem + +from .chemistry import GenerativeMetric, _pool_map + +# PoseCheck defaults, from posecheck/utils/strain.py. +NUM_CONFS = 50 +MAX_DISPLACEMENT = 0.1 +FORCE_CONSTANT = 1.0e5 + + +def _uff_energy(mol: Chem.Mol, conf_id: int = 0) -> float | None: + try: + ff = AllChem.UFFGetMoleculeForceField(mol, confId=conf_id) + return float(ff.CalcEnergy()) + except Exception: + return None + + +def _local_energy(mol: Chem.Mol) -> float | None: + """UFF energy after relaxing the pose under per-atom position restraints. + + The restraints are what make this "strain of *this* conformer" rather than + "how bad is this geometry": every atom may move at most ``MAX_DISPLACEMENT`` + from where the model put it, so clashes and stretched bonds relax out while + the conformational hypothesis is preserved. + """ + probe = Chem.Mol(mol) + try: + ff = AllChem.UFFGetMoleculeForceField(probe, confId=0) + for i in range(probe.GetNumAtoms()): + ff.UFFAddPositionConstraint(i, MAX_DISPLACEMENT, FORCE_CONSTANT) + ff.Minimize() + return float(ff.CalcEnergy()) + except Exception: + return None + + +def _global_energy( + mol: Chem.Mol, num_confs: int = NUM_CONFS, seed: int = 0xF00D +) -> float | None: + """Lowest UFF energy over freshly embedded conformers plus the relaxed pose. + + Including the freely-relaxed pose (PoseCheck's ``relax_global_on_pose``) + matters: ETKDG can miss the basin the model actually generated, and without + it a molecule whose pose is *better* than anything embedded would score a + spurious negative strain. + """ + energies: list[float] = [] + + freed = Chem.Mol(mol) + try: + AllChem.UFFOptimizeMolecule(freed, maxIters=1000) + e = _uff_energy(freed) + if e is not None: + energies.append(e) + except Exception: + pass + + embedded = Chem.Mol(mol) + try: + params = AllChem.ETKDGv3() + params.randomSeed = seed + embedded.RemoveAllConformers() + AllChem.EmbedMultipleConfs(embedded, numConfs=num_confs, params=params) + AllChem.UFFOptimizeMoleculeConfs(embedded, maxIters=1000) + energies += [ + e + for c in embedded.GetConformers() + if (e := _uff_energy(embedded, c.GetId())) is not None + ] + except Exception: + pass + + return min(energies) if energies else None + + +def posecheck_strain_energy(mol: Chem.Mol | None) -> float | None: + """``local - global`` in kcal/mol; ``None`` when either term fails.""" + if mol is None or mol.GetNumConformers() == 0: + return None + local = _local_energy(mol) + if local is None: + return None + glob = _global_energy(mol) + if glob is None: + return None + return local - glob + + +class PoseCheckStrainEnergy(GenerativeMetric): + """Mean PoseCheck strain over molecules where it is calculable. + + Molecules that fail (unembeddable, no UFF parameters) are dropped rather + than counted as zero, matching how ``AverageStrainEnergy`` handles failures. + + Defaults to the MEDIAN because that is what TABASCO's table reports. Pass + ``median=False`` for the mean, but never compare the two: the tail makes + them differ by ~20x. ``self.values`` holds the raw per-molecule numbers if + another statistic is wanted without paying for a second pass. + """ + + def __init__(self, median: bool = True, **kwargs): + super().__init__(**kwargs) + self.median = median + self.values: list[float] = [] + + def reset(self): + """Clear all accumulated strain energies.""" + super().reset() + self.values = [] + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate strain energies for the provided molecules.""" + vals = _pool_map(posecheck_strain_energy, [m for m in mols if m is not None]) + self.values += [v for v in vals if v is not None] + + def compute(self) -> torch.Tensor: + """Return the configured aggregate over accumulated energies.""" + if not self.values: + return torch.tensor(float("nan")) + t = torch.tensor(self.values, dtype=torch.float64) + return (t.median() if self.median else t.mean()).float() diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/scaffold.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/scaffold.py new file mode 100644 index 0000000000000000000000000000000000000000..d9a318b4cfcf99905c630321eb2e7857c87cd9f8 --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/scaffold.py @@ -0,0 +1,145 @@ +"""Scaffold-conditioned scores, with one row per attempted generation.""" + +from collections import defaultdict +from statistics import mean + +import torch +from rdkit import Chem, DataStructs +from rdkit.Chem import rdFingerprintGenerator + +from morph.utils.rdkit_utils import mol_is_valid, smiles_from_mol + + +def _bonds(graph, no_bond): + matrix = torch.full((graph.num_nodes, graph.num_nodes), no_bond) + matrix[graph.edge_index[0], graph.edge_index[1]] = graph.e.long() + return matrix + + +def _aligned_rmsd(generated, source): + """Measure deformation with fixed atom correspondence and a proper rotation.""" + predicted = generated.double() - generated.double().mean(dim=0) + reference = source.double() - source.double().mean(dim=0) + if not torch.isfinite(predicted).all() or not torch.isfinite(reference).all(): + return None + u, _, vh = torch.linalg.svd(predicted.T @ reference) + correction = torch.eye(3, dtype=u.dtype, device=u.device) + correction[-1, -1] = torch.linalg.det(u @ vh) + residual = predicted @ (u @ correction @ vh) - reference + return float(residual.square().sum(-1).mean().sqrt()) + + +def scaffold_row(source, target, generated, mol, vocab, *, allow_charged=False): + """Score one attempt; graph coordinates must already be in physical units. + + Retained atoms keep their relative order through integration. Compare their + types, charges, bonds and *unaligned* coordinates to the exact input graph. + Invalid RDKit conversions still contribute to all-attempt denominators. + """ + keep_mask = getattr(generated, "keep_mask", None) + kept = generated.filter_nodes(keep_mask.bool()) if keep_mask is not None else None + same_size = kept is not None and kept.num_nodes == source.num_nodes + no_bond = list(vocab.edge_tokens).index("") + preserved = bool( + same_size + and torch.equal(source.a, kept.a) + and torch.equal(source.c, kept.c) + and torch.equal(_bonds(source, no_bond), _bonds(kept, no_bond)) + ) + rmsd = ( + float((kept.x - source.x).square().sum(-1).mean().sqrt()) if same_size else None + ) + valid = mol_is_valid(mol, allow_charged=allow_charged) + smiles = smiles_from_mol(mol) if valid else None + target_mol = target.to_rdkit_mol( + vocab.atom_tokens, vocab.edge_tokens, vocab.charge_tokens + ) + target_smiles = smiles_from_mol(target_mol) + heavy = torch.tensor([symbol != "H" for symbol in vocab.atom_tokens]) + return { + "sample_id": int(source.sample_id.item()), + "scaffold_id": int(source.scaffold_id.item()), + "validity": valid, + "retention": preserved, + "valid_retention": valid and preserved, + "rmsd": rmsd, + "rmsd_aligned": _aligned_rmsd(kept.x, source.x) if same_size else None, + "added_atoms": generated.num_nodes - source.num_nodes, + "added_heavy_atoms": int(heavy[generated.a].sum() - heavy[source.a].sum()), + "smiles": smiles if preserved else None, + "recovered": bool(valid and preserved and smiles and smiles == target_smiles), + # Same atom order as the decoded molecule; no hidden target geometry is + # used to excuse an inherited clash. Keep plain lists for DDP/JSON. + "posebusters_reference": { + "kept_indices": keep_mask.bool().nonzero(as_tuple=True)[0].tolist(), + "positions": source.x.tolist(), + } + if preserved and mol is not None and mol.GetNumAtoms() == generated.num_nodes + else None, + } + + +def summarize_scaffold_rows(rows): + """Macro-average over scaffolds after gathering all ranks' attempt rows. + + Uniqueness/diversity use chemically valid, scaffold-preserving outputs. + ``unique_yield`` divides by *all* attempts; ``uniqueness`` by eligible outputs. + Empty groups score zero. Diversity is zero with fewer than two eligible + outputs. Recovery is an any-hit rate per scaffold, at the evaluated budget. + Repeated sample IDs from distributed-sampler padding are counted only once. + """ + rows = list({row["sample_id"]: row for row in rows}.values()) + if not rows: + return {} + groups = defaultdict(list) + for row in rows: + groups[row["scaffold_id"]].append(row) + generator = rdFingerprintGenerator.GetMorganGenerator(radius=3, fpSize=2048) + fingerprints = {} + summaries = [] + for group in groups.values(): + smis = [row["smiles"] for row in group if row["smiles"] is not None] + for smi in set(smis) - fingerprints.keys(): + fingerprints[smi] = generator.GetFingerprint(Chem.MolFromSmiles(smi)) + fps = [fingerprints[smi] for smi in smis] + distance = sum( + sum( + 1 - value + for value in DataStructs.BulkTanimotoSimilarity(fp, fps[i + 1 :]) + ) + for i, fp in enumerate(fps[:-1]) + ) + rmsds = [row["rmsd"] for row in group if row["rmsd"] is not None] + aligned_rmsds = [ + row["rmsd_aligned"] for row in group if row.get("rmsd_aligned") is not None + ] + summary = { + key: mean(row[key] for row in group) + for key in ( + "validity", + "retention", + "valid_retention", + "added_atoms", + "added_heavy_atoms", + ) + } + summary.update( + no_growth=mean(row["added_atoms"] <= 0 for row in group), + no_heavy_atom_growth=mean(row["added_heavy_atoms"] <= 0 for row in group), + rmsd=mean(rmsds) if rmsds else float("nan"), + rmsd_aligned=mean(aligned_rmsds) if aligned_rmsds else float("nan"), + uniqueness=len(set(smis)) / len(smis) if smis else 0.0, + unique_yield=len(set(smis)) / len(group), + diversity=2 * distance / (len(fps) * (len(fps) - 1)) + if len(fps) > 1 + else 0.0, + recovery=float(any(row["recovered"] for row in group)), + ) + summaries.append(summary) + return { + **{key: mean(row[key] for row in summaries) for key in summaries[0]}, + "n_scaffolds": len(groups), + "n_samples": len(rows), + "min_samples_per_scaffold": min(map(len, groups.values())), + "max_samples_per_scaffold": max(map(len, groups.values())), + } diff --git a/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/scaffold_posebusters.py b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/scaffold_posebusters.py new file mode 100644 index 0000000000000000000000000000000000000000..04fb32af97fe9821712754d02423bdd1f4d9c6cc --- /dev/null +++ b/geom/scaffold_murcko_substituent_lora_r16/runtime/src/morph/utils/metrics/scaffold_posebusters.py @@ -0,0 +1,80 @@ +"""Scaffold-aware clash scoring without changing the strict PoseBusters report.""" + +import numpy as np + + +def inherited_clash_counts(mol, reference, parameters, *, tolerance=1e-4): + """Count all clashes and the non-worsened pairs inherited from input geometry. + + Both distances use the generated molecule's topology/bounds. Only retained + pairs with a verified atom mapping can be exempted; the hidden target is + never consulted. ``tolerance`` is a numerical tolerance in Angstroms, not + allowance for additional overlap. An unavailable check raises and the caller + retains the strict failure. + """ + from posebusters.modules.distance_geometry import check_geometry # noqa: PLC0415 + + indices = reference["kept_indices"] + positions = np.asarray(reference["positions"], dtype=float) + if ( + positions.shape != (len(indices), 3) + or not np.isfinite(positions).all() + or len(set(indices)) != len(indices) + or any(i < 0 or i >= mol.GetNumAtoms() for i in indices) + ): + raise ValueError("Invalid scaffold coordinate mapping") + original = dict(zip(indices, positions, strict=True)) + geometry = check_geometry(mol, **parameters) + pairs = geometry["details"]["clash"] + threshold = parameters.get("threshold_clash", 0.2) + clashes = pairs.loc[pairs["bound_percent_error"] < -threshold] + if not len(clashes) or len(clashes) != geometry["results"]["number_clashes"]: + raise ValueError("Detailed clash check disagrees with the strict result") + inherited = 0 + for pair in clashes.itertuples(index=False): + i, j = pair.atom_pair + if i not in original or j not in original: + continue + baseline_distance = float(np.linalg.norm(original[i] - original[j])) + baseline_clashes = baseline_distance < (1 - threshold) * pair.lower_bound + if baseline_clashes and pair.distance + tolerance >= baseline_distance: + inherited += 1 + return len(clashes), inherited + + +def scaffold_posebusters_metrics(checks, mols, contexts, n_generated, parameters): + """Exempt inherited clashes; require every other strict check to pass.""" + # Normalize booleans and missing entries before replacing a failed check; + # pandas 3 rejects assigning integer 1 into a boolean column. + adjusted = checks.astype(float) + clash_column = "internal_steric_clash" + exemptions = inherited_pairs = failed_analysis = 0 + if clash_column in adjusted: + column_index = adjusted.columns.get_loc(clash_column) + for index, (mol, reference) in enumerate(zip(mols, contexts, strict=True)): + # Missing context/NaN checks never earn an exemption. + if reference is None or adjusted.iat[index, column_index] != 0: + continue + try: + count, inherited = inherited_clash_counts(mol, reference, parameters) + except Exception: + failed_analysis += 1 + continue + inherited_pairs += inherited + if count == inherited: + adjusted.iat[index, column_index] = 1 + exemptions += 1 + passed = adjusted.fillna(0).astype(bool).all(axis=1) + n_passed = int(passed.sum()) if len(adjusted.columns) else 0 + return { + "scaffold_validity": n_passed / n_generated if n_generated else float("nan"), + "scaffold_n_all_pass": float(n_passed), + "scaffold_internal_steric_clash_over_generated": ( + float(adjusted[clash_column].fillna(0).sum()) / n_generated + if n_generated and clash_column in adjusted + else 0.0 + ), + "scaffold_n_clash_exemptions": float(exemptions), + "scaffold_n_inherited_clash_pairs": float(inherited_pairs), + "scaffold_n_clash_analysis_failed": float(failed_analysis), + }