diff --git a/Dockerfile b/Dockerfile index 8a925f211a1c75e9106bc144f078b10ba6cd3bec..89b9e2d649e78725d1c109c40d7d5f3c28851b73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,8 +87,6 @@ USER user RUN --mount=target=requirements.txt,source=requirements.txt \ pip install --no-cache-dir --upgrade -r requirements.txt -# Pull chemprop from github -RUN git clone https://github.com/chemprop/chemprop.git $HOME/app/chemprop # build an empty conda environment with appropriate Python version RUN conda create --name chemprop_env python=3.11* @@ -96,11 +94,11 @@ RUN conda create --name chemprop_env python=3.11* SHELL ["conda", "run", "--no-capture-output", "-n", "chemprop_env", "/bin/bash", "-c"] # Follow the installation instructions then clear the cache -ADD $HOME/app/chemprop /data/chemprop +ADD chemprop chemprop ADD LICENSE.txt pyproject.toml README.md ./ RUN conda install pytorch cpuonly -c pytorch && \ conda clean --all --yes && \ - python -m pip install ./chemprop && \ + python -m pip install . && \ python -m pip cache purge diff --git a/chemprop/__init__.py b/chemprop/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cb6b14530dd59ea33b5d1ee0d159e2bfb6ed5bf6 --- /dev/null +++ b/chemprop/__init__.py @@ -0,0 +1,5 @@ +from . import data, featurizers, models, nn, utils, conf, exceptions, schedulers + +__all__ = ["data", "featurizers", "models", "nn", "utils", "conf", "exceptions", "schedulers"] + +__version__ = "2.0.0" diff --git a/chemprop/cli/common.py b/chemprop/cli/common.py new file mode 100644 index 0000000000000000000000000000000000000000..e5d4583f29c65a8d160fb8f9000731456a45bf4e --- /dev/null +++ b/chemprop/cli/common.py @@ -0,0 +1,183 @@ +import logging +from argparse import ArgumentParser, Namespace, ArgumentError +from pathlib import Path + +from chemprop.cli.utils import LookupAction +from chemprop.cli.utils.args import uppercase +from chemprop.featurizers import MoleculeFeaturizerRegistry, RxnMode, AtomFeatureMode + +logger = logging.getLogger(__name__) + + +def add_common_args(parser: ArgumentParser) -> ArgumentParser: + data_args = parser.add_argument_group("Shared input data args") + data_args.add_argument( + "-s", + "--smiles-columns", + nargs="+", + help="The column names in the input CSV containing SMILES strings. If unspecified, uses the the 0th column.", + ) + data_args.add_argument( + "-r", + "--reaction-columns", + nargs="+", + help="The column names in the input CSV containing reaction SMILES in the format 'REACTANT>AGENT>PRODUCT', where 'AGENT' is optional.", + ) + data_args.add_argument( + "--no-header-row", + action="store_true", + help="If specified, the first row in the input CSV will not be used as column names.", + ) + + dataloader_args = parser.add_argument_group("Dataloader args") + dataloader_args.add_argument( + "-n", + "--num-workers", + type=int, + default=0, + help="""Number of workers for parallel data loading (0 means sequential). +Warning: setting num_workers>0 can cause hangs on Windows and MacOS.""", + ) + dataloader_args.add_argument("-b", "--batch-size", type=int, default=64, help="Batch size.") + + parser.add_argument( + "--accelerator", default="auto", help="Passed directly to the lightning Trainer()." + ) + parser.add_argument( + "--devices", + default="auto", + help="Passed directly to the lightning Trainer(). If specifying multiple devices, must be a single string of comma separated devices, e.g. '1, 2'.", + ) + + featurization_args = parser.add_argument_group("Featurization args") + featurization_args.add_argument( + "--rxn-mode", + "--reaction-mode", + type=uppercase, + default="REAC_DIFF", + choices=list(RxnMode.keys()), + help="""Choices for construction of atom and bond features for reactions (case insensitive): +- 'reac_prod': concatenates the reactants feature with the products feature. +- 'reac_diff': concatenates the reactants feature with the difference in features between reactants and products. (Default) +- 'prod_diff': concatenates the products feature with the difference in features between reactants and products. +- 'reac_prod_balance': concatenates the reactants feature with the products feature, balances imbalanced reactions. +- 'reac_diff_balance': concatenates the reactants feature with the difference in features between reactants and products, balances imbalanced reactions. +- 'prod_diff_balance': concatenates the products feature with the difference in features between reactants and products, balances imbalanced reactions.""", + ) + # TODO: Update documenation for multi_hot_atom_featurizer_mode + featurization_args.add_argument( + "--multi-hot-atom-featurizer-mode", + type=uppercase, + default="V2", + choices=list(AtomFeatureMode.keys()), + help="""Choices for multi-hot atom featurization scheme. This will affect both non-reatction and reaction feturization (case insensitive): +- `V1`: Corresponds to the original configuration employed in the Chemprop V1. +- `V2`: Tailored for a broad range of molecules, this configuration encompasses all elements in the first four rows of the periodic table, along with iodine. It is the default in Chemprop V2. +- `ORGANIC`: Designed specifically for use with organic molecules for drug research and development, this configuration includes a subset of elements most common in organic chemistry, including H, B, C, N, O, F, Si, P, S, Cl, Br, and I.""", + ) + featurization_args.add_argument( + "--keep-h", + action="store_true", + help="Whether hydrogens explicitly specified in input should be kept in the mol graph.", + ) + featurization_args.add_argument( + "--add-h", action="store_true", help="Whether hydrogens should be added to the mol graph." + ) + featurization_args.add_argument( + "--features-generators", + nargs="+", + action=LookupAction(MoleculeFeaturizerRegistry), + help="Method(s) of generating additional features.", + ) + featurization_args.add_argument( + "--descriptors-path", + type=Path, + help="Path to extra descriptors to concatenate to learned representation.", + ) + # TODO: Add in v2.1 + # featurization_args.add_argument( + # "--phase-features-path", + # help="Path to features used to indicate the phase of the data in one-hot vector form. Used in spectra datatype.", + # ) + featurization_args.add_argument( + "--no-descriptor-scaling", action="store_true", help="Turn off extra descriptor scaling." + ) + featurization_args.add_argument( + "--no-atom-feature-scaling", + action="store_true", + help="Turn off extra atom feature scaling.", + ) + featurization_args.add_argument( + "--no-atom-descriptor-scaling", + action="store_true", + help="Turn off extra atom descriptor scaling.", + ) + featurization_args.add_argument( + "--no-bond-feature-scaling", + action="store_true", + help="Turn off extra bond feature scaling.", + ) + featurization_args.add_argument( + "--atom-features-path", + nargs="+", + action="append", + help="If a single path is given, it's assumed to correspond to the 0-th molecule. Or, it can be a two-tuple of molecule index and path to additional atom features to supply before message passing. E.g., `--atom-features-path 0 /path/to/features_0.npz` indicates that the features at the given path should be supplied to the 0-th component. To supply additional features for multiple components, repeat this argument on the command line for each component's respective values, e.g., `--atom-features-path [...] --atom-features-path [...]`.", + ) + featurization_args.add_argument( + "--atom-descriptors-path", + nargs="+", + action="append", + help="If a single path is given, it's assumed to correspond to the 0-th molecule. Or, it can be a two-tuple of molecule index and path to additional atom descriptors to supply after message passing. E.g., `--atom-descriptors-path 0 /path/to/descriptors_0.npz` indicates that the descriptors at the given path should be supplied to the 0-th component. To supply additional descriptors for multiple components, repeat this argument on the command line for each component's respective values, e.g., `--atom-descriptors-path [...] --atom-descriptors-path [...]`.", + ) + featurization_args.add_argument( + "--bond-features-path", + nargs="+", + action="append", + help="If a single path is given, it's assumed to correspond to the 0-th molecule. Or, it can be a two-tuple of molecule index and path to additional bond features to supply before message passing. E.g., `--bond-features-path 0 /path/to/features_0.npz` indicates that the features at the given path should be supplied to the 0-th component. To supply additional features for multiple components, repeat this argument on the command line for each component's respective values, e.g., `--bond-features-path [...] --bond-features-path [...]`.", + ) + # TODO: Add in v2.2 + # parser.add_argument( + # "--constraints-path", + # help="Path to constraints applied to atomic/bond properties prediction.", + # ) + + return parser + + +def process_common_args(args: Namespace) -> Namespace: + for key in ["atom_features_path", "atom_descriptors_path", "bond_features_path"]: + inds_paths = getattr(args, key) + + if not inds_paths: + continue + + ind_path_dict = {} + + for ind_path in inds_paths: + if len(ind_path) > 2: + raise ArgumentError( + argument=None, + message="Too many arguments given for atom features/descriptors or bond features. It can be either a two-tuple of molecule index and a path, or a single path (assumed to be the 0-th molecule).", + ) + + if len(ind_path) == 1: + ind = 0 + path = ind_path[0] + else: + ind, path = ind_path + + if ind_path_dict.get(int(ind), None): + raise ArgumentError( + argument=None, + message=f"Duplicate atom features/descriptors or bond features given for molecule index {ind}.", + ) + + ind_path_dict[int(ind)] = Path(path) + + setattr(args, key, ind_path_dict) + + return args + + +def validate_common_args(args): + pass diff --git a/chemprop/cli/conf.py b/chemprop/cli/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..56763ca544fba998dfde04a245b8038beb785037 --- /dev/null +++ b/chemprop/cli/conf.py @@ -0,0 +1,8 @@ +from datetime import datetime +import logging +import os +from pathlib import Path + +LOG_DIR = Path(os.getenv("CHEMPROP_LOG_DIR", "chemprop_logs")) +LOG_LEVELS = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG] +NOW = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") diff --git a/chemprop/cli/convert.py b/chemprop/cli/convert.py new file mode 100644 index 0000000000000000000000000000000000000000..0a20ff994772223aec4e83400d5586b0ba522560 --- /dev/null +++ b/chemprop/cli/convert.py @@ -0,0 +1,55 @@ +from argparse import ArgumentError, ArgumentParser, Namespace +import sys +import logging +from pathlib import Path + +from chemprop.cli.utils import Subcommand +from chemprop.utils.v1_to_v2 import convert_model_file_v1_to_v2 + +logger = logging.getLogger(__name__) + + +class ConvertSubcommand(Subcommand): + COMMAND = "convert" + HELP = "convert a v1 model checkpoint (.pt) to a v2 model checkpoint (.ckpt)" + + @classmethod + def add_args(cls, parser: ArgumentParser) -> ArgumentParser: + parser.add_argument( + "-i", + "--input-path", + required=True, + type=Path, + help="The path to a v1 model .pt checkpoint file.", + ) + parser.add_argument( + "-o", + "--output-path", + type=Path, + help="The path to which the converted model will be saved. Defaults to 'CURRENT_DIRECTORY/STEM_OF_INPUT_v2.ckpt'", + ) + return parser + + @classmethod + def func(cls, args: Namespace): + if args.output_path is None: + args.output_path = Path(args.input_path.stem + "_v2.ckpt") + if args.output_path.suffix != ".ckpt": + raise ArgumentError( + argument=None, message=f"Output must be a `.ckpt` file. Got {args.output_path}" + ) + + logger.info( + f"Converting v1 model checkpoint '{args.input_path}' to v2 model checkpoint '{args.output_path}'..." + ) + convert_model_file_v1_to_v2(args.input_path, args.output_path) + + +if __name__ == "__main__": + parser = ArgumentParser() + parser = ConvertSubcommand.add_args(parser) + + logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True) + + args = parser.parse_args() + ConvertSubcommand.func(args) diff --git a/chemprop/cli/fingerprint.py b/chemprop/cli/fingerprint.py new file mode 100644 index 0000000000000000000000000000000000000000..9ac52f2f88e0344e7e1e99ae9a4e63d42c40c88e --- /dev/null +++ b/chemprop/cli/fingerprint.py @@ -0,0 +1,197 @@ +import logging +import sys +from argparse import ArgumentError, ArgumentParser, Namespace +from pathlib import Path + +import numpy as np +import pandas as pd +import torch + +from chemprop import data +from chemprop.cli.common import add_common_args, process_common_args, validate_common_args +from chemprop.cli.utils import Subcommand, build_data_from_files, make_dataset +from chemprop.featurizers import MoleculeFeaturizerRegistry +from chemprop.models import load_model +from chemprop.nn.loss import LossFunctionRegistry +from chemprop.utils import Factory + +logger = logging.getLogger(__name__) + + +class FingerprintSubcommand(Subcommand): + COMMAND = "fingerprint" + HELP = "use a pretrained chemprop model for to calculate learned representations" + + @classmethod + def add_args(cls, parser: ArgumentParser) -> ArgumentParser: + parser = add_common_args(parser) + parser.add_argument( + "-i", + "--test-path", + required=True, + type=Path, + help="Path to an input CSV file containing SMILES.", + ) + parser.add_argument( + "-o", + "--output", + "--preds-path", + type=Path, + help="Path to which predictions will be saved. If the file extension is .npz, they will be saved as a npz file, respectively. Otherwise, will save predictions as a CSV. The index of the model will be appended to the filename's stem. By default, predictions will be saved to the same location as '--test-path' with '_fps' appended, i.e., 'PATH/TO/TEST_PATH_fps_0.csv'.", + ) + parser.add_argument( + "--model-path", + required=True, + type=Path, + help="Path to either a single pretrained model checkpoint (.ckpt) or single pretrained model file (.pt) or to a directory that contains these files. If a directory, will recursively search and predict on all found models.", + ) + parser.add_argument( + "--ffn-block-index", + required=True, + type=int, + default=-1, + help="The index indicates which linear layer returns the encoding in the FFN. An index of 0 denotes the post-aggregation representation through a 0-layer MLP, while an index of 1 represents the output from the first linear layer in the FFN, and so forth.", + ) + + return parser + + @classmethod + def func(cls, args: Namespace): + args = process_common_args(args) + validate_common_args(args) + args = process_fingerprint_args(args) + main(args) + + +def process_fingerprint_args(args: Namespace) -> Namespace: + if args.test_path.suffix not in [".csv"]: + raise ArgumentError( + argument=None, message=f"Input data must be a CSV file. Got {args.test_path}" + ) + if args.output is None: + args.output = args.test_path.parent / (args.test_path.stem + "_fps.csv") + if args.output.suffix not in [".csv", ".npz"]: + raise ArgumentError( + argument=None, message=f"Output must be a CSV or NPZ file. Got '{args.output}'." + ) + return args + + +def find_models(model_path: Path): + if model_path.suffix in [".ckpt", ".pt"]: + return [model_path] + elif model_path.is_dir(): + return list(model_path.rglob("*.ckpt")) + list(model_path.rglob("*.pt")) + + +def make_fingerprint_for_model( + args: Namespace, model_path: Path, multicomponent: bool, output_path: Path +): + model = load_model(model_path, multicomponent) + model.eval() + + bounded = any( + isinstance(model.criterion, LossFunctionRegistry[loss_function]) + for loss_function in LossFunctionRegistry.keys() + if "bounded" in loss_function + ) + + format_kwargs = dict( + no_header_row=args.no_header_row, + smiles_cols=args.smiles_columns, + rxn_cols=args.reaction_columns, + target_cols=None, + ignore_cols=None, + splits_col=None, + weight_col=None, + bounded=bounded, + ) + + if args.features_generators is not None: + # TODO: MorganFeaturizers take radius, length, and include_chirality as arguements. Should we expose these through the CLI? + features_generators = [ + Factory.build(MoleculeFeaturizerRegistry[features_generator]) + for features_generator in args.features_generators + ] + else: + features_generators = None + + featurization_kwargs = dict( + features_generators=features_generators, keep_h=args.keep_h, add_h=args.add_h + ) + + test_data = build_data_from_files( + args.test_path, + **format_kwargs, + p_descriptors=args.descriptors_path, + p_atom_feats=args.atom_features_path, + p_bond_feats=args.bond_features_path, + p_atom_descs=args.atom_descriptors_path, + **featurization_kwargs, + ) + logger.info(f"test size: {len(test_data[0])}") + test_dsets = [ + make_dataset(d, args.rxn_mode, args.multi_hot_atom_featurizer_mode) for d in test_data + ] + + if multicomponent: + test_dset = data.MulticomponentDataset(test_dsets) + else: + test_dset = test_dsets[0] + + test_loader = data.build_dataloader(test_dset, args.batch_size, args.num_workers, shuffle=False) + + logger.info(model) + + with torch.no_grad(): + if multicomponent: + encodings = [ + model.encoding(batch.bmgs, batch.V_ds, batch.X_d, args.ffn_block_index) + for batch in test_loader + ] + else: + encodings = [ + model.encoding(batch.bmg, batch.V_d, batch.X_d, args.ffn_block_index) + for batch in test_loader + ] + H = torch.cat(encodings, 0).numpy() + + if output_path.suffix in [".npz"]: + np.savez(output_path, H=H) + elif output_path.suffix == ".csv": + fingerprint_columns = [f"fp_{i}" for i in range(H.shape[1])] + df_fingerprints = pd.DataFrame(H, columns=fingerprint_columns) + df_fingerprints.to_csv(output_path, index=False) + else: + raise ArgumentError( + argument=None, message=f"Output must be a CSV or npz file. Got {args.output}." + ) + logger.info(f"Fingerprints saved to '{output_path}'") + + +def main(args): + match (args.smiles_columns, args.reaction_columns): + case [None, None]: + n_components = 1 + case [_, None]: + n_components = len(args.smiles_columns) + case [None, _]: + n_components = len(args.reaction_columns) + case _: + n_components = len(args.smiles_columns) + len(args.reaction_columns) + + multicomponent = n_components > 1 + + for i, model_path in enumerate(find_models(args.model_path)): + logger.info(f"Fingerprints with model at '{model_path}'") + output_path = args.output.parent / f"{args.output.stem}_{i}{args.output.suffix}" + make_fingerprint_for_model(args, model_path, multicomponent, output_path) + + +if __name__ == "__main__": + parser = ArgumentParser() + parser = FingerprintSubcommand.add_args(parser) + + logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True) + args = parser.parse_args() + args = FingerprintSubcommand.func(args) diff --git a/chemprop/cli/hpopt.py b/chemprop/cli/hpopt.py new file mode 100644 index 0000000000000000000000000000000000000000..93821045da2675d934f6fea17aa15841e5dd2e2b --- /dev/null +++ b/chemprop/cli/hpopt.py @@ -0,0 +1,442 @@ +import json +import logging +import sys +from argparse import ArgumentParser, Namespace +from copy import deepcopy +from pathlib import Path +import torch +from lightning import pytorch as pl +from lightning.pytorch.callbacks import EarlyStopping + +from chemprop.cli.common import add_common_args, process_common_args, validate_common_args +from chemprop.cli.train import ( + add_train_args, + build_datasets, + build_model, + build_splits, + normalize_inputs, + process_train_args, + validate_train_args, +) +from chemprop.cli.utils.command import Subcommand +from chemprop.data import build_dataloader +from chemprop.featurizers import MoleculeFeaturizerRegistry +from chemprop.nn import AggregationRegistry +from chemprop.nn.transforms import UnscaleTransform +from chemprop.nn.utils import Activation +from chemprop.utils import Factory + +NO_RAY = False +DEFAULT_SEARCH_SPACE = {} +try: + import ray + from ray import tune + from ray.train import CheckpointConfig, RunConfig, ScalingConfig + from ray.train.lightning import ( + RayDDPStrategy, + RayLightningEnvironment, + RayTrainReportCallback, + prepare_trainer, + ) + from ray.train.torch import TorchTrainer + from ray.tune.schedulers import ASHAScheduler + + DEFAULT_SEARCH_SPACE = { + "activation": tune.choice(categories=list(Activation.keys())), + "aggregation": tune.choice(categories=list(AggregationRegistry.keys())), + "aggregation_norm": tune.quniform(lower=1, upper=200, q=1), + "batch_size": tune.choice([16, 32, 64, 128, 256]), + "depth": tune.qrandint(lower=2, upper=6, q=1), + "dropout": tune.choice([tune.choice([0.0]), tune.quniform(lower=0.05, upper=0.4, q=0.05)]), + "ffn_hidden_dim": tune.qrandint(lower=300, upper=2400, q=100), + "ffn_num_layers": tune.qrandint(lower=1, upper=3, q=1), + "final_lr_ratio": tune.loguniform(lower=1e-2, upper=1), + "message_hidden_dim": tune.qrandint(lower=300, upper=2400, q=100), + "init_lr_ratio": tune.loguniform(lower=1e-2, upper=1), + "max_lr": tune.loguniform(lower=1e-4, upper=1e-2), + "warmup_epochs": None, + } +except ImportError: + NO_RAY = True + +NO_HYPEROPT = False +try: + from ray.tune.search.hyperopt import HyperOptSearch +except ImportError: + NO_HYPEROPT = True + +# NO_OPTUNA = False +# try: +# from ray.tune.search.optuna import OptunaSearch +# except ImportError: +# NO_OPTUNA = True + + +logger = logging.getLogger(__name__) + +SEARCH_SPACE = DEFAULT_SEARCH_SPACE + +SEARCH_PARAM_KEYWORDS_MAP = { + "basic": ["depth", "ffn_num_layers", "dropout", "ffn_hidden_dim", "message_hidden_dim"], + "learning_rate": ["max_lr", "init_lr_ratio", "final_lr_ratio", "warmup_epochs"], + "all": list(DEFAULT_SEARCH_SPACE.keys()), +} + + +class HpoptSubcommand(Subcommand): + COMMAND = "hpopt" + HELP = "perform hyperparameter optimization on the given task" + + @classmethod + def add_args(cls, parser: ArgumentParser) -> ArgumentParser: + parser = add_common_args(parser) + parser = add_train_args(parser) + return add_hpopt_args(parser) + + @classmethod + def func(cls, args: Namespace): + args = process_common_args(args) + args = process_train_args(args) + args = process_hpopt_args(args) + validate_common_args(args) + validate_train_args(args) + main(args) + + +def add_hpopt_args(parser: ArgumentParser) -> ArgumentParser: + hpopt_args = parser.add_argument_group("Chemprop hyperparameter optimization arguments") + + hpopt_args.add_argument( + "--search-parameter-keywords", + type=str, + nargs="+", + default=["basic"], + help=f"""The model parameters over which to search for an optimal hyperparameter configuration. + Some options are bundles of parameters or otherwise special parameter operations. + + Special keywords: + basic - the default set of hyperparameters for search: depth, ffn_num_layers, dropout, message_hidden_dim, and ffn_hidden_dim. + learning_rate - search for max_lr, init_lr_ratio, final_lr_ratio, and warmup_epochs. The search for init_lr and final_lr values + are defined as fractions of the max_lr value. The search for warmup_epochs is as a fraction of the total epochs used. + all - include search for all 13 inidividual keyword options + + Individual supported parameters: + {list(DEFAULT_SEARCH_SPACE.keys())} + """, + ) + + hpopt_args.add_argument( + "--hpopt-save-dir", + type=Path, + help="Directory to save the hyperparameter optimization results", + ) + + raytune_args = parser.add_argument_group("Ray Tune arguments") + + raytune_args.add_argument( + "--raytune-num-samples", + type=int, + default=10, + help="Passed directly to Ray Tune TuneConfig to control number of trials to run", + ) + + raytune_args.add_argument( + "--raytune-search-algorithm", + choices=["random", "hyperopt"], # , "optuna"], + default="hyperopt", + help="Passed to Ray Tune TuneConfig to control search algorithm", + ) + + raytune_args.add_argument( + "--raytune-num-workers", + type=int, + default=1, + help="Passed directly to Ray Tune ScalingConfig to control number of workers to use", + ) + + raytune_args.add_argument( + "--raytune-use-gpu", + action="store_true", + help="Passed directly to Ray Tune ScalingConfig to control whether to use GPUs", + ) + + raytune_args.add_argument( + "--raytune-num-checkpoints-to-keep", + type=int, + default=1, + help="Passed directly to Ray Tune CheckpointConfig to control number of checkpoints to keep", + ) + + raytune_args.add_argument( + "--raytune-grace-period", + type=int, + default=10, + help="Passed directly to Ray Tune ASHAScheduler to control grace period", + ) + + raytune_args.add_argument( + "--raytune-reduction-factor", + type=int, + default=2, + help="Passed directly to Ray Tune ASHAScheduler to control reduction factor", + ) + + hyperopt_args = parser.add_argument_group("Hyperopt arguments") + + hyperopt_args.add_argument( + "--hyperopt-n-initial-points", + type=int, + default=20, + help="Passed directly to HyperOptSearch to control number of initial points to sample", + ) + + hyperopt_args.add_argument( + "--hyperopt-random-state-seed", + type=int, + default=None, + help="Passed directly to HyperOptSearch to control random state seed", + ) + + return parser + + +def process_hpopt_args(args: Namespace) -> Namespace: + if args.hpopt_save_dir is None: + args.hpopt_save_dir = Path(f"chemprop_hpopt/{args.data_path.stem}") + + args.hpopt_save_dir.mkdir(exist_ok=True, parents=True) + + search_parameters = set() + + for keyword in args.search_parameter_keywords: + if keyword not in SEARCH_PARAM_KEYWORDS_MAP and keyword not in SEARCH_SPACE: + raise ValueError( + f"Search parameter keyword: {keyword} not in available options: {list(SEARCH_PARAM_KEYWORDS_MAP.keys()) + list(SEARCH_SPACE.keys())}." + ) + + search_parameters.update( + SEARCH_PARAM_KEYWORDS_MAP[keyword] + if keyword in SEARCH_PARAM_KEYWORDS_MAP + else [keyword] + ) + + args.search_parameter_keywords = list(search_parameters) + + return args + + +def build_search_space(search_parameters: list[str], train_epochs: int) -> dict: + if "warmup_epochs" in search_parameters and SEARCH_SPACE.get("warmup_epochs", None) is None: + SEARCH_SPACE["warmup_epochs"] = tune.qrandint(lower=1, upper=train_epochs // 2, q=1) + + return {param: SEARCH_SPACE[param] for param in search_parameters} + + +def update_args_with_config(args: Namespace, config: dict) -> Namespace: + args = deepcopy(args) + + for key, value in config.items(): + match key: + case "final_lr_ratio": + setattr(args, "final_lr", value * args.max_lr) + + case "init_lr_ratio": + setattr(args, "init_lr", value * args.max_lr) + + case _: + assert key in args, f"Key: {key} not found in args." + setattr(args, key, value) + + return args + + +def train_model(config, args, train_dset, val_dset, logger, output_transform, input_transforms): + update_args_with_config(args, config) + + train_loader = build_dataloader( + train_dset, args.batch_size, args.num_workers, seed=args.data_seed + ) + val_loader = build_dataloader(val_dset, args.batch_size, args.num_workers, shuffle=False) + + seed = args.pytorch_seed if args.pytorch_seed is not None else torch.seed() + + torch.manual_seed(seed) + + model = build_model(args, train_loader.dataset, output_transform, input_transforms) + logger.info(model) + + monitor_mode = "min" if model.metrics[0].minimize else "max" + logger.debug(f"Evaluation metric: '{model.metrics[0].alias}', mode: '{monitor_mode}'") + + patience = args.patience if args.patience is not None else args.epochs + early_stopping = EarlyStopping("val_loss", patience=patience, mode=monitor_mode) + + trainer = pl.Trainer( + accelerator=args.accelerator, + devices=args.devices, + max_epochs=args.epochs, + gradient_clip_val=args.grad_clip, + strategy=RayDDPStrategy(find_unused_parameters=True), + callbacks=[RayTrainReportCallback(), early_stopping], + plugins=[RayLightningEnvironment()], + deterministic=args.pytorch_seed is not None, + ) + trainer = prepare_trainer(trainer) + trainer.fit(model, train_loader, val_loader) + + +def tune_model( + args, train_dset, val_dset, logger, monitor_mode, output_transform, input_transforms +): + scheduler = ASHAScheduler( + max_t=args.epochs, + grace_period=min(args.raytune_grace_period, args.epochs), + reduction_factor=args.raytune_reduction_factor, + ) + + scaling_config = ScalingConfig( + num_workers=args.raytune_num_workers, use_gpu=args.raytune_use_gpu + ) + + checkpoint_config = CheckpointConfig( + num_to_keep=args.raytune_num_checkpoints_to_keep, + checkpoint_score_attribute="val_loss", + checkpoint_score_order=monitor_mode, + ) + + run_config = RunConfig( + checkpoint_config=checkpoint_config, + storage_path=args.hpopt_save_dir.absolute() / "ray_results", + ) + + ray_trainer = TorchTrainer( + lambda config: train_model( + config, args, train_dset, val_dset, logger, output_transform, input_transforms + ), + scaling_config=scaling_config, + run_config=run_config, + ) + + match args.raytune_search_algorithm: + case "random": + search_alg = None + case "hyperopt": + if NO_HYPEROPT: + raise ImportError( + "HyperOptSearch requires hyperopt to be installed. Use 'pip -U install hyperopt' to install." + ) + + search_alg = HyperOptSearch( + n_initial_points=args.hyperopt_n_initial_points, + random_state_seed=args.hyperopt_random_state_seed, + ) + # case "optuna": + # if NO_OPTUNA: + # raise ImportError( + # "OptunaSearch requires optuna to be installed. Use 'pip -U install optuna' to install." + # ) + + # search_alg = OptunaSearch() + + tune_config = tune.TuneConfig( + metric="val_loss", + mode=monitor_mode, + num_samples=args.raytune_num_samples, + scheduler=scheduler, + search_alg=search_alg, + ) + + tuner = tune.Tuner( + ray_trainer, + param_space={ + "train_loop_config": build_search_space(args.search_parameter_keywords, args.epochs) + }, + tune_config=tune_config, + ) + + return tuner.fit() + + +def main(args: Namespace): + if NO_RAY: + raise ImportError( + "Ray Tune requires ray to be installed. Use 'pip -U install ray[tune]' to install." + ) + + format_kwargs = dict( + no_header_row=args.no_header_row, + smiles_cols=args.smiles_columns, + rxn_cols=args.reaction_columns, + target_cols=args.target_columns, + ignore_cols=args.ignore_columns, + splits_col=args.splits_column, + weight_col=args.weight_column, + bounded=args.loss_function is not None and "bounded" in args.loss_function, + ) + + if args.features_generators is not None: + # TODO: MorganFeaturizers take radius, length, and include_chirality as arguements. Should we expose these through the CLI? + features_generators = [ + Factory.build(MoleculeFeaturizerRegistry[features_generator]) + for features_generator in args.features_generators + ] + else: + features_generators = None + + featurization_kwargs = dict( + features_generators=features_generators, keep_h=args.keep_h, add_h=args.add_h + ) + + train_data, val_data, test_data = build_splits(args, format_kwargs, featurization_kwargs) + train_dset, val_dset, test_dset = build_datasets(args, train_data[0], val_data[0], test_data[0]) + + input_transforms = normalize_inputs(train_dset, val_dset, args) + + if "regression" in args.task_type: + output_scaler = train_dset.normalize_targets() + val_dset.normalize_targets(output_scaler) + logger.info(f"Train data: mean = {output_scaler.mean_} | std = {output_scaler.scale_}") + output_transform = UnscaleTransform.from_standard_scaler(output_scaler) + else: + output_transform = None + + train_loader = build_dataloader( + train_dset, args.batch_size, args.num_workers, seed=args.data_seed + ) + + model = build_model(args, train_loader.dataset, output_transform, input_transforms) + monitor_mode = "min" if model.metrics[0].minimize else "max" + + results = tune_model( + args, train_dset, val_dset, logger, monitor_mode, output_transform, input_transforms + ) + + best_result = results.get_best_result() + best_config = best_result.config + best_checkpoint = best_result.checkpoint # Get best trial's best checkpoint + + logger.info(f"Saving best hyperparameter parameters: {best_config}") + + with open(args.hpopt_save_dir / "best_params.json", "w") as f: + json.dump(best_config, f, indent=4) + + logger.info(f"Saving best hyperparameter configuration checkpoint: {best_checkpoint}") + + torch.save(best_checkpoint, args.hpopt_save_dir / "best_checkpoint.ckpt") + + result_df = results.get_dataframe() + + logger.info(f"Saving hyperparameter optimization results: {result_df}") + + result_df.to_csv(args.hpopt_save_dir / "all_progress.csv", index=False) + + ray.shutdown() + + +if __name__ == "__main__": + parser = ArgumentParser() + parser = HpoptSubcommand.add_args(parser) + + logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True) + args = parser.parse_args() + HpoptSubcommand.func(args) diff --git a/chemprop/cli/main.py b/chemprop/cli/main.py new file mode 100644 index 0000000000000000000000000000000000000000..ec78cda77cddbb2151c02d8dfdf6bdace5dff341 --- /dev/null +++ b/chemprop/cli/main.py @@ -0,0 +1,80 @@ +from configargparse import ArgumentParser +import logging +import sys +from pathlib import Path + +from chemprop.cli.train import TrainSubcommand +from chemprop.cli.predict import PredictSubcommand +from chemprop.cli.convert import ConvertSubcommand +from chemprop.cli.fingerprint import FingerprintSubcommand +from chemprop.cli.hpopt import HpoptSubcommand + +from chemprop.cli.utils import pop_attr +from chemprop.cli.conf import LOG_DIR, LOG_LEVELS, NOW + +logger = logging.getLogger(__name__) + +SUBCOMMANDS = [ + TrainSubcommand, + PredictSubcommand, + ConvertSubcommand, + FingerprintSubcommand, + HpoptSubcommand, +] + + +def construct_parser(): + parser = ArgumentParser() + subparsers = parser.add_subparsers(title="mode", dest="mode", required=True) + + parent = ArgumentParser(add_help=False) + parent.add_argument( + "--logfile", + "--log", + nargs="?", + const="default", + help=f"The path to which the log file should be written. Specifying just the flag (i.e., '--log/--logfile') will automatically log to a file '{LOG_DIR}/MODE/TIMESTAMP.log', where 'MODE' is the CLI mode chosen. An example 'TIMESTAMP' is {NOW}.", + ) + parent.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="The verbosity level, specify the flag multiple times to increase verbosity.", + ) + + parents = [parent] + for subcommand in SUBCOMMANDS: + subcommand.add(subparsers, parents) + + return parser + + +def main(): + parser = construct_parser() + args = parser.parse_args() + logfile, verbose, mode, func = ( + pop_attr(args, attr) for attr in ["logfile", "verbose", "mode", "func"] + ) + + match logfile: + case None: + handler = logging.StreamHandler(sys.stderr) + case "default": + (LOG_DIR / mode).mkdir(parents=True, exist_ok=True) + handler = logging.FileHandler(str(LOG_DIR / mode / f"{NOW}.log")) + case _: + Path(logfile).parent.mkdir(parents=True, exist_ok=True) + handler = logging.FileHandler(logfile) + + logging.basicConfig( + handlers=[handler], + format="%(asctime)s - %(levelname)s:%(name)s - %(message)s", + level=LOG_LEVELS[min(verbose, len(LOG_LEVELS) - 1)], + datefmt="%Y-%m-%dT%H:%M:%S", + force=True, + ) + + logger.info(f"Running in mode '{mode}' with args: {vars(args)}") + + func(args) diff --git a/chemprop/cli/predict.py b/chemprop/cli/predict.py new file mode 100644 index 0000000000000000000000000000000000000000..e801702124fad59c841821bbac41c9f3d3590478 --- /dev/null +++ b/chemprop/cli/predict.py @@ -0,0 +1,315 @@ +from argparse import ArgumentError, ArgumentParser, Namespace +import logging +from pathlib import Path +import sys +import pandas as pd + +from lightning import pytorch as pl +import torch + +from chemprop import data +from chemprop.nn.loss import LossFunctionRegistry +from chemprop.nn.predictors import MulticlassClassificationFFN +from chemprop.models import load_model + +from chemprop.cli.utils import Subcommand, build_data_from_files, make_dataset +from chemprop.cli.common import add_common_args, process_common_args, validate_common_args + + +logger = logging.getLogger(__name__) + + +class PredictSubcommand(Subcommand): + COMMAND = "predict" + HELP = "use a pretrained chemprop model for prediction" + + @classmethod + def add_args(cls, parser: ArgumentParser) -> ArgumentParser: + parser = add_common_args(parser) + return add_predict_args(parser) + + @classmethod + def func(cls, args: Namespace): + args = process_common_args(args) + validate_common_args(args) + args = process_predict_args(args) + main(args) + + +def add_predict_args(parser: ArgumentParser) -> ArgumentParser: + parser.add_argument( + "-i", + "--test-path", + required=True, + type=Path, + help="Path to an input CSV file containing SMILES.", + ) + parser.add_argument( + "-o", + "--output", + "--preds-path", + type=Path, + help="Path to which predictions will be saved. If the file extension is .pkl, will be saved as a pickle file. Otherwise, will save predictions as a CSV. The index of the model will be appended to the filename's stem. By default, predictions will be saved to the same location as '--test-path' with '_preds' appended, i.e., 'PATH/TO/TEST_PATH_preds_0.csv'.", + ) + parser.add_argument( + "--drop-extra-columns", + action="store_true", + help="Whether to drop all columns from the test data file besides the SMILES columns and the new prediction columns.", + ) + parser.add_argument( + "--model-path", + required=True, + type=Path, + help="Path to either a single pretrained model checkpoint (.ckpt) or single pretrained model file (.pt) or to a directory that contains these files. If a directory, will recursively search and predict on all found models.", + ) + parser.add_argument( + "--target-columns", + nargs="+", + help="Column names to save the predictions to. If not provided, the predictions will be saved to columns named 'pred_0', 'pred_1', etc.", + ) + + # TODO: add uncertainty and calibration in v2.1 + # unc_args = parser.add_argument_group("Uncertainty and calibration args") + # unc_args.add_argument("--cal-path") + # unc_args.add_argument("--cal-features-path") + # unc_args.add_argument("--cal-atom-features-path") + # unc_args.add_argument("--cal-bond-features-path") + # unc_args.add_argument("--cal-atom-descriptors-path") + # unc_args.add_argument( + # "--ensemble-variance", + # type=None, + # help="Deprecated. Whether to calculate the variance of ensembles as a measure of epistemic uncertainty. If True, the variance is saved as an additional column for each target in the preds_path.", + # ) + # unc_args.add_argument( + # "--individual-ensemble-predictions", + # type=bool, + # action="store_true", + # help="Whether to return the predictions made by each of the individual models rather than the average of the ensemble.", + # ) + # unc_args.add_argument( + # "--uncertainty-method", + # #action=RegistryAction(TODO: make register for uncertainty methods) + # help="The method of calculating uncertainty.", + # ) + # unc_args.add_argument( + # "--calibration-method", + # #action=RegistryAction(TODO: make register for calibration methods) + # help="Methods used for calibrating the uncertainty calculated with uncertainty method.", + # ) + # unc_args.add_argument( + # "--evaluation-method", + # #action=RegistryAction(TODO: make register for evaluation methods) + # type=list[str], + # help="The methods used for evaluating the uncertainty performance if the test data provided includes targets. Available methods are [nll, miscalibration_area, ence, spearman] or any available classification or multiclass metric.", + # ) + # unc_args.add_argument( + # "--evaluation-scores-path", + # help="Location to save the results of uncertainty evaluations.", + # ) + # unc_args.add_argument( + # "--uncertainty-dropout-p", + # type=float, + # default=0.1, + # help="The probability to use for Monte Carlo dropout uncertainty estimation.", + # ) + # unc_args.add_argument( + # "--dropout-sampling-size", + # type=int, + # default=10, + # help="The number of samples to use for Monte Carlo dropout uncertainty estimation. Distinct from the dropout used during training.", + # ) + # unc_args.add_argument( + # "--calibration-interval-percentile", + # type=float, + # default=95, + # help="Sets the percentile used in the calibration methods. Must be in the range (1,100).", + # ) + # unc_args.add_argument( + # "--regression-calibrator-metric", + # choices=['stdev', 'interval'], + # help="Regression calibrators can output either a stdev or an inverval.", + # ) + # unc_args.add_argument( + # "--calibrationipath", + # help="Path to data file to be used for uncertainty calibration.", + # ) + # unc_args.add_argument( + # "--calibration-features-path", + # type=list[str], + # help="Path to features data to be used with the uncertainty calibration dataset.", + # ) + # unc_args.add_argument( + # "--calibration-phase-features-path", + # help=" ", + # ) + # unc_args.add_argument( + # "--calibration-atom-descriptors-path", + # help="Path to the extra atom descriptors.", + # ) + # unc_args.add_argument( + # "--calibration-bond-descriptors-path", + # help="Path to the extra bond descriptors that will be used as bond features to featurize a given molecule.", + # ) + + return parser + + +def process_predict_args(args: Namespace) -> Namespace: + if args.test_path.suffix not in [".csv"]: + raise ArgumentError( + argument=None, message=f"Input data must be a CSV file. Got {args.test_path}" + ) + if args.output is None: + args.output = args.test_path.parent / (args.test_path.stem + "_preds.csv") + if args.output.suffix not in [".csv", ".pkl"]: + raise ArgumentError( + argument=None, message=f"Output must be a CSV or Pickle file. Got {args.output}" + ) + return args + + +def find_models(model_path: Path): + if model_path.suffix in [".ckpt", ".pt"]: + return [model_path] + elif model_path.is_dir(): + return list(model_path.rglob("*.ckpt")) + list(model_path.rglob("*.pt")) + + +def make_prediction_for_model( + args: Namespace, model_path: Path, multicomponent: bool, output_path: Path +): + model = load_model(model_path, multicomponent) + + bounded = any( + isinstance(model.criterion, LossFunctionRegistry[loss_function]) + for loss_function in LossFunctionRegistry.keys() + if "bounded" in loss_function + ) + + format_kwargs = dict( + no_header_row=args.no_header_row, + smiles_cols=args.smiles_columns, + rxn_cols=args.reaction_columns, + target_cols=None, + ignore_cols=None, + splits_col=None, + weight_col=None, + bounded=bounded, + ) + featurization_kwargs = dict( + features_generators=args.features_generators, keep_h=args.keep_h, add_h=args.add_h + ) + + test_data = build_data_from_files( + args.test_path, + **format_kwargs, + p_descriptors=args.descriptors_path, + p_atom_feats=args.atom_features_path, + p_bond_feats=args.bond_features_path, + p_atom_descs=args.atom_descriptors_path, + **featurization_kwargs, + ) + logger.info(f"test size: {len(test_data[0])}") + test_dsets = [ + make_dataset(d, args.rxn_mode, args.multi_hot_atom_featurizer_mode) for d in test_data + ] + + if multicomponent: + test_dset = data.MulticomponentDataset(test_dsets) + else: + test_dset = test_dsets[0] + + # TODO: add uncertainty and calibration + # if args.cal_path is not None: + # cal_data = build_data_from_files( + # args.cal_path, + # **format_kwargs, + # target_columns=args.target_columns, + # p_features=args.cal_features_path, + # p_atom_feats=args.cal_atom_features_path, + # p_bond_feats=args.cal_bond_features_path, + # p_atom_descs=args.cal_atom_descriptors_path, + # **featurization_kwargs, + # ) + # logger.info(f"calibration size: {len(cal_data)}") + # else: + # cal_data = None + + test_loader = data.build_dataloader(test_dset, args.batch_size, args.num_workers, shuffle=False) + # TODO: add uncertainty and calibration + # if cal_data is not None: + # cal_dset = make_dataset(cal_data, bond_messages, args.rxn_mode) + # cal_loader = data.build_dataloader(cal_dset, args.batch_size, args.num_workers, shuffle=False) + # else: + # cal_loader = None + + logger.info(model) + + trainer = pl.Trainer( + logger=False, enable_progress_bar=True, accelerator=args.accelerator, devices=args.devices + ) + + predss = trainer.predict(model, test_loader) + + # TODO: add uncertainty and calibration + # if cal_dset is not None: + # if args.task_type == "regression": + # model.loc, model.scale = float(scaler.mean_), float(scaler.scale_) + # predss_cal = trainer.predict(model, cal_loader)[0] + + # TODO: might want to write a shared function for this as train.py might also want to do this. + df_test = pd.read_csv(args.test_path) + preds = torch.concat(predss, 0) + + if isinstance(model.predictor, MulticlassClassificationFFN): + preds = torch.argmax(preds, dim=-1) + + if args.target_columns is not None: + assert ( + len(args.target_columns) == model.n_tasks + ), "Number of target columns must match the number of tasks." + target_columns = args.target_columns + else: + target_columns = [ + f"pred_{i}" for i in range(preds.shape[1]) + ] # TODO: need to improve this for cases like multi-task MVE and multi-task multiclass + + df_test[target_columns] = preds + if output_path.suffix == ".pkl": + df_test = df_test.reset_index(drop=True) + df_test.to_pickle(output_path) + else: + df_test.to_csv(output_path, index=False) + logger.info(f"Predictions saved to '{output_path}'") + + +def main(args): + match (args.smiles_columns, args.reaction_columns): + case [None, None]: + n_components = 1 + case [_, None]: + n_components = len(args.smiles_columns) + case [None, _]: + n_components = len(args.reaction_columns) + case _: + n_components = len(args.smiles_columns) + len(args.reaction_columns) + + multicomponent = n_components > 1 + + model_paths = find_models(args.model_path) + + for i, model_path in enumerate(model_paths): + logger.info(f"Predicting with model at '{model_path}'") + output_path = args.output.parent / Path( + str(args.output.stem) + f"_{i}" + str(args.output.suffix) + ) + make_prediction_for_model(args, model_path, multicomponent, output_path) + + +if __name__ == "__main__": + parser = ArgumentParser() + parser = PredictSubcommand.add_args(parser) + + logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True) + args = parser.parse_args() + args = PredictSubcommand.func(args) diff --git a/chemprop/cli/train.py b/chemprop/cli/train.py new file mode 100644 index 0000000000000000000000000000000000000000..7d3de117f09c386020dc0a59407a219437056eaa --- /dev/null +++ b/chemprop/cli/train.py @@ -0,0 +1,1007 @@ +import json +import logging +import sys +from copy import deepcopy +from pathlib import Path + +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from configargparse import ArgumentError, ArgumentParser, Namespace +from lightning import pytorch as pl +from lightning.pytorch.callbacks import EarlyStopping, ModelCheckpoint +from lightning.pytorch.loggers import CSVLogger, TensorBoardLogger + +from chemprop.cli.common import add_common_args, process_common_args, validate_common_args +from chemprop.cli.conf import NOW +from chemprop.cli.utils import ( + LookupAction, + Subcommand, + build_data_from_files, + get_column_names, + make_dataset, + parse_indices, +) +from chemprop.cli.utils.args import uppercase +from chemprop.data import ( + MoleculeDataset, + MolGraphDataset, + MulticomponentDataset, + ReactionDatapoint, + SplitType, + build_dataloader, + make_split_indices, + split_data_by_indices, +) +from chemprop.featurizers import MoleculeFeaturizerRegistry +from chemprop.models import MPNN, MulticomponentMPNN, save_model +from chemprop.nn import AggregationRegistry, LossFunctionRegistry, MetricRegistry, PredictorRegistry +from chemprop.nn.message_passing import ( + AtomMessagePassing, + BondMessagePassing, + MulticomponentMessagePassing, +) +from chemprop.nn.transforms import GraphTransform, ScaleTransform, UnscaleTransform +from chemprop.nn.utils import Activation +from chemprop.utils import Factory + +logger = logging.getLogger(__name__) + + +class TrainSubcommand(Subcommand): + COMMAND = "train" + HELP = "train a chemprop model" + parser = None + + @classmethod + def add_args(cls, parser: ArgumentParser) -> ArgumentParser: + parser = add_common_args(parser) + parser = add_train_args(parser) + cls.parser = parser + return parser + + @classmethod + def func(cls, args: Namespace): + args = process_common_args(args) + validate_common_args(args) + args = process_train_args(args) + validate_train_args(args) + + args.output_dir.mkdir(exist_ok=True, parents=True) + save_config(cls.parser, args) + main(args) + + +def add_train_args(parser: ArgumentParser) -> ArgumentParser: + parser.add_argument( + "--config-path", + type=Path, + is_config_file=True, + help="Path to a configuration file. Command line arguments override values in the configuration file.", + ) + parser.add_argument( + "-i", + "--data-path", + type=Path, + help="Path to an input CSV file containing SMILES and the associated target values.", + ) + parser.add_argument( + "-o", + "--output-dir", + "--save-dir", + type=Path, + help="Directory where training outputs will be saved. Defaults to 'CURRENT_DIRECTORY/chemprop_training/STEM_OF_INPUT/TIME_STAMP'.", + ) + + # TODO: Add in v2.1 + # parser.add_argument( + # "--checkpoint-dir", + # help="Directory from which to load model checkpoints (walks directory and ensembles all models that are found).", + # ) + # parser.add_argument("--checkpoint-path", help="Path to model checkpoint (:code:`.pt` file).") + # parser.add_argument( + # "--checkpoint-paths", + # type=list[str], + # help="List of paths to model checkpoints (:code:`.pt` files).", + # ) + # # TODO: Is this a prediction only argument? + # parser.add_argument( + # "--checkpoint", + # help="Location of checkpoint(s) to use for ... If the location is a directory, chemprop walks it and ensembles all models that are found. If the location is a path or list of paths to model checkpoints (:code:`.pt` files), only those models will be loaded.", + # ) + + # TODO: Add in v2.1; see if we can tell lightning how often to log training loss + # parser.add_argument( + # "--log-frequency", + # type=int, + # default=10, + # help="The number of batches between each logging of the training loss.", + # ) + + transfer_args = parser.add_argument_group("transfer learning args") + transfer_args.add_argument( + "--model-frzn", + help="Path to model checkpoint file to be loaded for overwriting and freezing weights.", + ) + transfer_args.add_argument( + "--frzn-ffn-layers", + type=int, + default=0, + help="Overwrites weights for the first n layers of the ffn from checkpoint model (specified checkpoint_frzn), where n is specified in the input. Automatically also freezes mpnn weights.", + ) + # transfer_args.add_argument( + # "--freeze-first-only", + # action="store_true", + # help="Determines whether or not to use checkpoint_frzn for just the first encoder. Default (False) is to use the checkpoint to freeze all encoders. (only relevant for number_of_molecules > 1, where checkpoint model has number_of_molecules = 1)", + # ) + + # TODO: Add in v2.1 + # parser.add_argument( + # "--resume-experiment", + # action="store_true", + # help="Whether to resume the experiment. Loads test results from any folds that have already been completed and skips training those folds.", + # ) + # parser.add_argument( + # "--config-path", + # help="Path to a :code:`.json` file containing arguments. Any arguments present in the config file will override arguments specified via the command line or by the defaults.", + # ) + parser.add_argument( + "--ensemble-size", + type=int, + default=1, + help="Number of models in ensemble for each splitting of data.", + ) + + # TODO: Add in v2.2 + # abt_args = parser.add_argument_group("atom/bond target args") + # abt_args.add_argument( + # "--is-atom-bond-targets", + # action="store_true", + # help="Whether this is atomic/bond properties prediction.", + # ) + # abt_args.add_argument( + # "--no-adding-bond-types", + # action="store_true", + # help="Whether the bond types determined by RDKit molecules added to the output of bond targets. This option is intended to be used with the :code:`is_atom_bond_targets`.", + # ) + # abt_args.add_argument( + # "--keeping-atom-map", + # action="store_true", + # help="Whether RDKit molecules keep the original atom mapping. This option is intended to be used when providing atom-mapped SMILES with the :code:`is_atom_bond_targets`.", + # ) + # abt_args.add_argument( + # "--no-shared-atom-bond-ffn", + # action="store_true", + # help="Whether the FFN weights for atom and bond targets should be independent between tasks.", + # ) + # abt_args.add_argument( + # "--weights-ffn-num-layers", + # type=int, + # default=2, + # help="Number of layers in FFN for determining weights used in constrained targets.", + # ) + + mp_args = parser.add_argument_group("message passing") + mp_args.add_argument( + "--message-hidden-dim", type=int, default=300, help="hidden dimension of the messages" + ) + mp_args.add_argument( + "--message-bias", action="store_true", help="add bias to the message passing layers" + ) + mp_args.add_argument("--depth", type=int, default=3, help="Number of message passing steps.") + mp_args.add_argument( + "--undirected", + action="store_true", + help="Pass messages on undirected bonds/edges (always sum the two relevant bond vectors).", + ) + mp_args.add_argument( + "--dropout", + type=float, + default=0.0, + help="dropout probability in message passing/FFN layers", + ) + mp_args.add_argument( + "--mpn-shared", + action="store_true", + help="Whether to use the same message passing neural network for all input molecules. Only relevant if :code:`number_of_molecules > 1`", + ) + mp_args.add_argument( + "--activation", + type=uppercase, + default="RELU", + choices=list(Activation.keys()), + help="activation function in message passing/FFN layers", + ) + mp_args.add_argument( + "--aggregation", + "--agg", + default="mean", + action=LookupAction(AggregationRegistry), + help="the aggregation mode to use during graph predictor", + ) + mp_args.add_argument( + "--aggregation-norm", + type=float, + default=100, + help="normalization factor by which to divide summed up atomic features for 'norm' aggregation", + ) + mp_args.add_argument( + "--atom-messages", action="store_true", help="pass messages on atoms rather than bonds" + ) + + # TODO: Add in v2.1 + # mpsolv_args = parser.add_argument_group("message passing with solvent") + # mpsolv_args.add_argument( + # "--reaction-solvent", + # action="store_true", + # help="Whether to adjust the MPNN layer to take as input a reaction and a molecule, and to encode them with separate MPNNs.", + # ) + # mpsolv_args.add_argument( + # "--bias-solvent", + # action="store_true", + # help="Whether to add bias to linear layers for solvent MPN if :code:`reaction_solvent` is True.", + # ) + # mpsolv_args.add_argument( + # "--hidden-size-solvent", + # type=int, + # default=300, + # help="Dimensionality of hidden layers in solvent MPN if :code:`reaction_solvent` is True.", + # ) + # mpsolv_args.add_argument( + # "--depth-solvent", + # type=int, + # default=3, + # help="Number of message passing steps for solvent if :code:`reaction_solvent` is True.", + # ) + + ffn_args = parser.add_argument_group("FFN args") + ffn_args.add_argument( + "--ffn-hidden-dim", type=int, default=300, help="hidden dimension in the FFN top model" + ) + ffn_args.add_argument( # TODO: the default in v1 was 2. (see weights_ffn_num_layers option) Do we really want the default to now be 1? + "--ffn-num-layers", type=int, default=1, help="number of layers in FFN top model" + ) + # TODO: Decide if we want to implment this in v2 + # ffn_args.add_argument( + # "--features-only", + # action="store_true", + # help="Use only the additional features in an FFN, no graph network.", + # ) + + extra_mpnn_args = parser.add_argument_group("extra MPNN args") + extra_mpnn_args.add_argument( + "--no-batch-norm", + action="store_true", + help="Don't use batch normalization after aggregation.", + ) + extra_mpnn_args.add_argument( + "--multiclass-num-classes", + type=int, + default=3, + help="Number of classes when running multiclass classification.", + ) + # TODO: Add in v2.1 + # extra_mpnn_args.add_argument( + # "--spectral-activation", + # default="exp", + # choices=["softplus", "exp"], + # help="Indicates which function to use in task_type spectra training to constrain outputs to be positive.", + # ) + + train_data_args = parser.add_argument_group("training input data args") + train_data_args.add_argument( + "-w", + "--weight-column", + help="the name of the column in the input CSV containg individual data weights", + ) + train_data_args.add_argument( + "--target-columns", + nargs="+", + help="Name of the columns containing target values. By default, uses all columns except the SMILES column and the :code:`ignore_columns`.", + ) + train_data_args.add_argument( + "--ignore-columns", + nargs="+", + help="Name of the columns to ignore when :code:`target_columns` is not provided.", + ) + # TODO: Add in v2.1 + # train_data_args.add_argument( + # "--spectra-phase-mask-path", + # help="Path to a file containing a phase mask array, used for excluding particular regions in spectra predictions.", + # ) + + train_args = parser.add_argument_group("training args") + train_args.add_argument( + "-t", + "--task-type", + default="regression", + action=LookupAction(PredictorRegistry), + help="Type of dataset. This determines the default loss function used during training. Defaults to regression.", + ) + train_args.add_argument( + "-l", + "--loss-function", + action=LookupAction(LossFunctionRegistry), + help="Loss function to use during training. If not specified, will use the default loss function for the given task type (see documentation).", + ) + train_args.add_argument( + "--v-kl", + "--evidential-regularization", + type=float, + default=0.0, + help="Value used in regularization for evidential loss function. The default value recommended by Soleimany et al.(2021) is 0.2. Optimal value is dataset-dependent; it is recommended that users test different values to find the best value for their model.", + ) + + train_args.add_argument( + "--eps", type=float, default=1e-8, help="evidential regularization epsilon" + ) + # TODO: Add in v2.1 + # train_args.add_argument( # TODO: Is threshold the same thing as the spectra target floor? I'm not sure but combined them. + # "-T", + # "--threshold", + # "--spectra-target-floor", + # type=float, + # default=1e-8, + # help="spectral threshold limit. v1 help string: Values in targets for dataset type spectra are replaced with this value, intended to be a small positive number used to enforce positive values.", + # ) + train_args.add_argument( + "--metrics", + "--metric", + nargs="+", + action=LookupAction(MetricRegistry), + help="evaluation metrics. If unspecified, will use the following metrics for given dataset types: regression->rmse, classification->roc, multiclass->ce ('cross entropy'), spectral->sid. If multiple metrics are provided, the 0th one will be used for early stopping and checkpointing", + ) + # TODO: Add in v2.1 + # train_args.add_argument( + # "--show-individual-scores", + # action="store_true", + # help="Show all scores for individual targets, not just average, at the end.", + # ) + train_args.add_argument( + "--task-weights", + nargs="+", + type=float, + help="the weight to apply to an individual task in the overall loss", + ) + train_args.add_argument( + "--warmup-epochs", + type=int, + default=2, + help="Number of epochs during which learning rate increases linearly from :code:`init_lr` to :code:`max_lr`. Afterwards, learning rate decreases exponentially from :code:`max_lr` to :code:`final_lr`.", + ) + + train_args.add_argument("--init-lr", type=float, default=1e-4, help="Initial learning rate.") + train_args.add_argument("--max-lr", type=float, default=1e-3, help="Maximum learning rate.") + train_args.add_argument("--final-lr", type=float, default=1e-4, help="Final learning rate.") + train_args.add_argument( + "--epochs", type=int, default=50, help="the number of epochs to train over" + ) + train_args.add_argument( + "--patience", + type=int, + default=None, + help="Number of epochs to wait for improvement before early stopping.", + ) + train_args.add_argument( + "--grad-clip", + type=float, + help="Passed directly to the lightning trainer which controls grad clipping. See the :code:`Trainer()` docstring for details.", + ) + # TODO: Add in v2.1 + # train_args.add_argument( + # "--class-balance", + # action="store_true", + # help="Trains with an equal number of positives and negatives in each batch.", + # ) + + split_args = parser.add_argument_group("split args") + split_args.add_argument( + "--split", + "--split-type", + type=uppercase, + default="RANDOM", + choices=list(SplitType.keys()), + help="Method of splitting the data into train/val/test (case insensitive).", + ) + split_args.add_argument( + "--split-sizes", + type=float, + nargs=3, + default=[0.8, 0.1, 0.1], + help="Split proportions for train/validation/test sets.", + ) + split_args.add_argument( + "--split-key-molecule", + type=int, + default=0, + help="The index of the key molecule used for splitting when multiple molecules are present and constrained split_type is used (e.g., 'scaffold_balanced' or 'random_with_repeated_smiles'). Note that this index begins with zero for the first molecule.", + ) + split_args.add_argument( + "-k", + "--num-folds", + type=int, + default=1, + help="Number of folds when performing cross validation.", + ) + split_args.add_argument( + "--save-smiles-splits", + action="store_true", + help="Save smiles for each train/val/test splits for prediction convenience later.", + ) + split_args.add_argument( + "--splits-file", + type=Path, + help="Path to a JSON file containing pre-defined splits for the input data, formatted as a list of dictionaries with keys 'train', 'val', and 'test' and values as lists of indices or strings formatted like '0-2,4'. See documentation for more details.", + ) + train_data_args.add_argument( + "--splits-column", + help="Name of the column in the input CSV file containing 'train', 'val', or 'test' for each row.", + ) + split_args.add_argument( + "--data-seed", + type=int, + default=0, + help="Random seed to use when splitting data into train/val/test sets. When :code`num_folds > 1`, the first fold uses this seed and all subsequent folds add 1 to the seed. Also used for shuffling data in :code:`build_dataloader` when :code:`shuffle` is True.", + ) + + parser.add_argument( + "--pytorch-seed", + type=int, + default=None, + help="Seed for PyTorch randomness (e.g., random initial weights).", + ) + + return parser + + +def process_train_args(args: Namespace) -> Namespace: + if args.config_path is None and args.data_path is None: + raise ArgumentError(argument=None, message="Data path must be provided for training.") + + if args.data_path.suffix not in [".csv"]: + raise ArgumentError( + argument=None, message=f"Input data must be a CSV file. Got {args.data_path}" + ) + if args.output_dir is None: + args.output_dir = Path(f"chemprop_training/{args.data_path.stem}/{NOW}") + + return args + + +def validate_train_args(args): + pass + + +def normalize_inputs(train_dset, val_dset, args): + multicomponent = isinstance(train_dset, MulticomponentDataset) + num_components = train_dset.n_components if multicomponent else 1 + + X_d_transform = None + V_f_transforms = [nn.Identity()] * num_components + E_f_transforms = [nn.Identity()] * num_components + V_d_transforms = [None] * num_components + graph_transforms = [] + + d_xd = train_dset.d_xd + d_vf = train_dset.d_vf + d_ef = train_dset.d_ef + d_vd = train_dset.d_vd + + if d_xd > 0 and not args.no_descriptor_scaling: + scaler = train_dset.normalize_inputs("X_d") + val_dset.normalize_inputs("X_d", scaler) + + scaler = scaler if not isinstance(scaler, list) else scaler[0] + + if scaler is not None: + logger.info( + f"Descriptors: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}" + ) + X_d_transform = ScaleTransform.from_standard_scaler(scaler) + + if d_vf > 0 and not args.no_atom_feature_scaling: + scaler = train_dset.normalize_inputs("V_f") + val_dset.normalize_inputs("V_f", scaler) + + scalers = [scaler] if not isinstance(scaler, list) else scaler + + for i, scaler in enumerate(scalers): + if scaler is None: + continue + + logger.info( + f"Atom features for mol {i}: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}" + ) + featurizer = ( + train_dset.datasets[i].featurizer if multicomponent else train_dset.featurizer + ) + V_f_transforms[i] = ScaleTransform.from_standard_scaler( + scaler, pad=featurizer.atom_fdim - featurizer.extra_atom_fdim + ) + + if d_ef > 0 and not args.no_bond_feature_scaling: + scaler = train_dset.normalize_inputs("E_f") + val_dset.normalize_inputs("E_f", scaler) + + scalers = [scaler] if not isinstance(scaler, list) else scaler + + for i, scaler in enumerate(scalers): + if scaler is None: + continue + + logger.info( + f"Bond features for mol {i}: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}" + ) + featurizer = ( + train_dset.datasets[i].featurizer if multicomponent else train_dset.featurizer + ) + E_f_transforms[i] = ScaleTransform.from_standard_scaler( + scaler, pad=featurizer.bond_fdim - featurizer.extra_bond_fdim + ) + + for V_f_transform, E_f_transform in zip(V_f_transforms, E_f_transforms): + graph_transforms.append(GraphTransform(V_f_transform, E_f_transform)) + + if d_vd > 0 and not args.no_atom_descriptor_scaling: + scaler = train_dset.normalize_inputs("V_d") + val_dset.normalize_inputs("V_d", scaler) + + scalers = [scaler] if not isinstance(scaler, list) else scaler + + for i, scaler in enumerate(scalers): + if scaler is None: + continue + + logger.info( + f"Atom descriptors for mol {i}: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}" + ) + V_d_transforms[i] = ScaleTransform.from_standard_scaler(scaler) + + return X_d_transform, graph_transforms, V_d_transforms + + +def save_config(parser: ArgumentParser, args: Namespace): + config_args = deepcopy(args) + for key, value in vars(config_args).items(): + if isinstance(value, Path): + setattr(config_args, key, str(value)) + + for key in ["atom_features_path", "atom_descriptors_path", "bond_features_path"]: + if getattr(config_args, key) is not None: + for index, path in getattr(config_args, key).items(): + getattr(config_args, key)[index] = str(path) + + config_path = str(args.output_dir / "config.toml") + parser.write_config_file(parsed_namespace=config_args, output_file_paths=[config_path]) + + +def save_smiles_splits(args: Namespace, output_dir, train_dset, val_dset, test_dset): + train_smis = train_dset.smiles + df_train = pd.DataFrame(train_smis, columns=args.smiles_columns) + df_train.to_csv(output_dir / "train_smiles.csv", index=False) + + val_smis = val_dset.smiles + df_val = pd.DataFrame(val_smis, columns=args.smiles_columns) + df_val.to_csv(output_dir / "val_smiles.csv", index=False) + + if test_dset is not None: + test_smis = test_dset.smiles + df_test = pd.DataFrame(test_smis, columns=args.smiles_columns) + df_test.to_csv(output_dir / "test_smiles.csv", index=False) + + +def build_splits(args, format_kwargs, featurization_kwargs): + """build the train/val/test splits""" + logger.info(f"Pulling data from file: {args.data_path}") + all_data = build_data_from_files( + args.data_path, + p_descriptors=args.descriptors_path, + p_atom_feats=args.atom_features_path, + p_bond_feats=args.bond_features_path, + p_atom_descs=args.atom_descriptors_path, + **format_kwargs, + **featurization_kwargs, + ) + + if args.splits_column is not None: + df = pd.read_csv( + args.data_path, header=None if args.no_header_row else "infer", index_col=False + ) + grouped = df.groupby(df[args.splits_column].str.lower()) + train_indices = grouped.groups.get("train", pd.Index([])).tolist() + val_indices = grouped.groups.get("val", pd.Index([])).tolist() + test_indices = grouped.groups.get("test", pd.Index([])).tolist() + train_indices, val_indices, test_indices = [train_indices], [val_indices], [test_indices] + + elif args.splits_file is not None: + with open(args.splits_file, "rb") as json_file: + split_idxss = json.load(json_file) + train_indices = [parse_indices(d["train"]) for d in split_idxss] + val_indices = [parse_indices(d["val"]) for d in split_idxss] + test_indices = [parse_indices(d["test"]) for d in split_idxss] + + else: + splitting_data = all_data[args.split_key_molecule] + if isinstance(splitting_data[0], ReactionDatapoint): + splitting_mols = [datapoint.rct for datapoint in splitting_data] + else: + splitting_mols = [datapoint.mol for datapoint in splitting_data] + train_indices, val_indices, test_indices = make_split_indices( + splitting_mols, args.split, args.split_sizes, args.data_seed, args.num_folds + ) + if not ( + SplitType.get(args.split) == SplitType.CV_NO_VAL + or SplitType.get(args.split) == SplitType.CV + ): + train_indices, val_indices, test_indices = ( + [train_indices], + [val_indices], + [test_indices], + ) + + train_data, val_data, test_data = split_data_by_indices( + all_data, train_indices, val_indices, test_indices + ) + for i_split in range(len(train_data)): + sizes = [len(train_data[i_split][0]), len(val_data[i_split][0]), len(test_data[i_split][0])] + logger.info(f"train/val/test split_{i_split} sizes: {sizes}") + + return train_data, val_data, test_data + + +def build_datasets(args, train_data, val_data, test_data): + """build the train/val/test datasets, where :attr:`test_data` may be None""" + multicomponent = len(train_data) > 1 + if multicomponent: + train_dsets = [ + make_dataset(data, args.rxn_mode, args.multi_hot_atom_featurizer_mode) + for data in train_data + ] + val_dsets = [ + make_dataset(data, args.rxn_mode, args.multi_hot_atom_featurizer_mode) + for data in val_data + ] + train_dset = MulticomponentDataset(train_dsets) + val_dset = MulticomponentDataset(val_dsets) + if len(test_data[0]) > 0: + test_dsets = [ + make_dataset(data, args.rxn_mode, args.multi_hot_atom_featurizer_mode) + for data in test_data + ] + test_dset = MulticomponentDataset(test_dsets) + else: + test_dset = None + else: + train_data = train_data[0] + val_data = val_data[0] + test_data = test_data[0] + + train_dset = make_dataset(train_data, args.rxn_mode, args.multi_hot_atom_featurizer_mode) + val_dset = make_dataset(val_data, args.rxn_mode, args.multi_hot_atom_featurizer_mode) + if len(test_data) > 0: + test_dset = make_dataset(test_data, args.rxn_mode, args.multi_hot_atom_featurizer_mode) + else: + test_dset = None + + return train_dset, val_dset, test_dset + + +def build_model( + args, + train_dset: MolGraphDataset | MulticomponentDataset, + output_transform: UnscaleTransform, + input_transforms: tuple[ScaleTransform, list[GraphTransform], list[ScaleTransform]], +) -> MPNN: + mp_cls = AtomMessagePassing if args.atom_messages else BondMessagePassing + + X_d_transform, graph_transforms, V_d_transforms = input_transforms + + if isinstance(train_dset, MulticomponentDataset): + mp_blocks = [ + mp_cls( + train_dset.datasets[i].featurizer.atom_fdim, + train_dset.datasets[i].featurizer.bond_fdim, + d_h=args.message_hidden_dim, + d_vd=( + train_dset.datasets[i].d_vd + if isinstance(train_dset.datasets[i], MoleculeDataset) + else 0 + ), + bias=args.message_bias, + depth=args.depth, + undirected=args.undirected, + dropout=args.dropout, + activation=args.activation, + V_d_transform=V_d_transforms[i], + graph_transform=graph_transforms[i], + ) + for i in range(train_dset.n_components) + ] + if args.mpn_shared: + if args.reaction_columns is not None and args.smiles_columns is not None: + raise ArgumentError( + argument=None, + message="Cannot use shared MPNN with both molecule and reaction data.", + ) + + mp_block = MulticomponentMessagePassing(mp_blocks, train_dset.n_components, args.mpn_shared) + # NOTE(degraff): this if/else block should be handled by the init of MulticomponentMessagePassing + # if args.mpn_shared: + # mp_block = MulticomponentMessagePassing(mp_blocks[0], n_components, args.mpn_shared) + # else: + d_xd = train_dset.datasets[0].d_xd + n_tasks = train_dset.datasets[0].Y.shape[1] + mpnn_cls = MulticomponentMPNN + else: + mp_block = mp_cls( + train_dset.featurizer.atom_fdim, + train_dset.featurizer.bond_fdim, + d_h=args.message_hidden_dim, + d_vd=train_dset.d_vd if isinstance(train_dset, MoleculeDataset) else 0, + bias=args.message_bias, + depth=args.depth, + undirected=args.undirected, + dropout=args.dropout, + activation=args.activation, + V_d_transform=V_d_transforms[0], + graph_transform=graph_transforms[0], + ) + d_xd = train_dset.d_xd + n_tasks = train_dset.Y.shape[1] + mpnn_cls = MPNN + + agg = Factory.build(AggregationRegistry[args.aggregation], norm=args.aggregation_norm) + predictor_cls = PredictorRegistry[args.task_type] + if args.loss_function is not None: + criterion = Factory.build( + LossFunctionRegistry[args.loss_function], + task_weights=args.task_weights, + v_kl=args.v_kl, + # threshold=args.threshold, TODO: Add in v2.1 + eps=args.eps, + ) + else: + criterion = None + if args.metrics is not None: + metrics = [Factory.build(MetricRegistry[metric]) for metric in args.metrics] + else: + metrics = None + + predictor = Factory.build( + predictor_cls, + input_dim=mp_block.output_dim + d_xd, + n_tasks=n_tasks, + hidden_dim=args.ffn_hidden_dim, + n_layers=args.ffn_num_layers, + dropout=args.dropout, + activation=args.activation, + criterion=criterion, + n_classes=args.multiclass_num_classes, + output_transform=output_transform, + # spectral_activation=args.spectral_activation, TODO: Add in v2.1 + ) + + if args.loss_function is None: + logger.info( + f"No loss function was specified! Using class default: {predictor_cls._T_default_criterion}" + ) + + if args.model_frzn is not None: + model = mpnn_cls.load_from_file(args.model_frzn) + model.message_passing.apply(lambda module: module.requires_grad_(False)) + model.message_passing.apply( + lambda m: setattr(m, "p", 0.0) if isinstance(m, torch.nn.Dropout) else None + ) + model.bn.apply(lambda module: module.requires_grad_(False)) + for idx in range(args.frzn_ffn_layers): + model.predictor.ffn[idx].requires_grad_(False) + setattr(model.predictor.ffn[idx + 1][1], "p", 0.0) + + return model + + return mpnn_cls( + mp_block, + agg, + predictor, + not args.no_batch_norm, + metrics, + args.warmup_epochs, + args.init_lr, + args.max_lr, + args.final_lr, + X_d_transform=X_d_transform, + ) + + +def train_model( + args, train_loader, val_loader, test_loader, output_dir, output_transform, input_transforms +): + for model_idx in range(args.ensemble_size): + model_output_dir = output_dir / f"model_{model_idx}" + model_output_dir.mkdir(exist_ok=True, parents=True) + + if args.pytorch_seed is None: + seed = torch.seed() + deterministic = False + else: + seed = args.pytorch_seed + model_idx + deterministic = True + + torch.manual_seed(seed) + + model = build_model(args, train_loader.dataset, output_transform, input_transforms) + logger.info(model) + + monitor_mode = "min" if model.metrics[0].minimize else "max" + logger.debug(f"Evaluation metric: '{model.metrics[0].alias}', mode: '{monitor_mode}'") + + try: + trainer_logger = TensorBoardLogger(model_output_dir, "trainer_logs") + except ModuleNotFoundError: + trainer_logger = CSVLogger(model_output_dir, "trainer_logs") + + checkpointing = ModelCheckpoint( + model_output_dir / "checkpoints", + "best-{epoch}-{val_loss:.2f}", + "val_loss", + mode=monitor_mode, + save_last=True, + ) + + patience = args.patience if args.patience is not None else args.epochs + early_stopping = EarlyStopping("val_loss", patience=patience, mode=monitor_mode) + + trainer = pl.Trainer( + logger=trainer_logger, + enable_progress_bar=True, + accelerator=args.accelerator, + devices=args.devices, + max_epochs=args.epochs, + callbacks=[checkpointing, early_stopping], + gradient_clip_val=args.grad_clip, + deterministic=deterministic, + ) + trainer.fit(model, train_loader, val_loader) + + if test_loader is not None: + predss = trainer.predict(dataloaders=test_loader) + preds = torch.concat(predss, 0).numpy() + + if isinstance(test_loader.dataset, MulticomponentDataset): + test_dset = test_loader.dataset.datasets[0] + else: + test_dset = test_loader.dataset + targets = test_dset.Y + mask = torch.from_numpy(np.isfinite(targets)) + targets = np.nan_to_num(targets, nan=0.0) + weights = torch.from_numpy(test_dset.weights) + lt_mask = ( + torch.from_numpy(test_dset.lt_mask) if test_dset.lt_mask[0] is not None else None + ) + gt_mask = ( + torch.from_numpy(test_dset.gt_mask) if test_dset.gt_mask[0] is not None else None + ) + preds_losses = [ + metric( + torch.from_numpy(preds), + torch.from_numpy(targets), + mask, + weights, + lt_mask, + gt_mask, + ) + for metric in model.metrics + ] + preds_metrics = { + f"entire_test/{m.alias}": l.item() for m, l in zip(model.metrics, preds_losses) + } + print(f"Entire Test Set results: {preds_metrics}") + + columns = get_column_names( + args.data_path, + args.smiles_columns, + args.reaction_columns, + args.target_columns, + args.ignore_columns, + args.splits_column, + args.weight_column, + args.no_header_row, + ) + names = test_loader.dataset.names + if isinstance(test_loader.dataset, MulticomponentDataset): + namess = list(zip(*names)) + else: + namess = [names] + if "multiclass" in args.task_type: + df_preds = pd.DataFrame(list(zip(*namess, preds)), columns=columns) + else: + df_preds = pd.DataFrame(list(zip(*namess, *preds.T)), columns=columns) + df_preds.to_csv(model_output_dir / "test_predictions.csv", index=False) + + best_model_path = checkpointing.best_model_path + model = model.__class__.load_from_checkpoint(best_model_path) + p_model = model_output_dir / "best.pt" + save_model(p_model, model) + logger.info(f"Best model saved to '{p_model}'") + + +def main(args): + format_kwargs = dict( + no_header_row=args.no_header_row, + smiles_cols=args.smiles_columns, + rxn_cols=args.reaction_columns, + target_cols=args.target_columns, + ignore_cols=args.ignore_columns, + splits_col=args.splits_column, + weight_col=args.weight_column, + bounded=args.loss_function is not None and "bounded" in args.loss_function, + ) + if args.features_generators is not None: + # TODO: MorganFeaturizers take radius, length, and include_chirality as arguements. Should we expose these through the CLI? + features_generators = [ + Factory.build(MoleculeFeaturizerRegistry[features_generator]) + for features_generator in args.features_generators + ] + else: + features_generators = None + + featurization_kwargs = dict( + features_generators=features_generators, keep_h=args.keep_h, add_h=args.add_h + ) + + splits = build_splits(args, format_kwargs, featurization_kwargs) + + for fold_idx, (train_data, val_data, test_data) in enumerate(zip(*splits)): + if args.num_folds == 1: + output_dir = args.output_dir + else: + output_dir = args.output_dir / f"fold_{fold_idx}" + + output_dir.mkdir(exist_ok=True, parents=True) + + train_dset, val_dset, test_dset = build_datasets(args, train_data, val_data, test_data) + + input_transforms = normalize_inputs(train_dset, val_dset, args) + + if args.save_smiles_splits: + save_smiles_splits(args, output_dir, train_dset, val_dset, test_dset) + + if "regression" in args.task_type: + output_scaler = train_dset.normalize_targets() + val_dset.normalize_targets(output_scaler) + logger.info(f"Train data: mean = {output_scaler.mean_} | std = {output_scaler.scale_}") + output_transform = UnscaleTransform.from_standard_scaler(output_scaler) + else: + output_transform = None + + train_loader = build_dataloader( + train_dset, args.batch_size, args.num_workers, seed=args.data_seed + ) + val_loader = build_dataloader(val_dset, args.batch_size, args.num_workers, shuffle=False) + if test_dset is not None: + test_loader = build_dataloader( + test_dset, args.batch_size, args.num_workers, shuffle=False + ) + else: + test_loader = None + + train_model( + args, + train_loader, + val_loader, + test_loader, + output_dir, + output_transform, + input_transforms, + ) + + +if __name__ == "__main__": + # TODO: update this old code or remove it. + parser = ArgumentParser() + parser = TrainSubcommand.add_args(parser) + + logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True) + args = parser.parse_args() + TrainSubcommand.func(args) diff --git a/chemprop/cli/utils/__init__.py b/chemprop/cli/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d37a575ee1826cbb35c636e231eeec5868092dc8 --- /dev/null +++ b/chemprop/cli/utils/__init__.py @@ -0,0 +1,31 @@ +from .args import bounded +from .actions import LookupAction +from .command import Subcommand +from .parsing import ( + build_data_from_files, + make_datapoints, + make_dataset, + get_column_names, + parse_indices, +) +from .utils import pop_attr, _pop_attr, _pop_attr_d, validate_loss_function + +__all__ = [ + "bounded", + "LookupAction", + "Subcommand", + "build_data_from_files", + "make_datapoints", + "make_dataset", + "get_column_names", + "parse_indices", + "actions", + "args", + "command", + "parsing", + "utils", + "pop_attr", + "_pop_attr", + "_pop_attr_d", + "validate_loss_function", +] diff --git a/chemprop/cli/utils/actions.py b/chemprop/cli/utils/actions.py new file mode 100644 index 0000000000000000000000000000000000000000..9d45c3569c5e19596299b128dc2e72713dfb0399 --- /dev/null +++ b/chemprop/cli/utils/actions.py @@ -0,0 +1,28 @@ +from argparse import Action, ArgumentParser, Namespace +from typing import Any, Mapping, Sequence + + +def LookupAction(obj: Mapping[str, Any]): + class LookupAction_(Action): + def __init__(self, option_strings, dest, default=None, choices=None, **kwargs): + if default not in obj.keys() and default is not None: + raise ValueError( + f"Invalid value for arg 'default': '{default}'. " + f"Expected one of {tuple(obj.keys())}" + ) + + kwargs["choices"] = choices if choices is not None else obj.keys() + kwargs["default"] = default + + super().__init__(option_strings, dest, **kwargs) + + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ): + setattr(namespace, self.dest, values) + + return LookupAction_ diff --git a/chemprop/cli/utils/args.py b/chemprop/cli/utils/args.py new file mode 100644 index 0000000000000000000000000000000000000000..50593a45aa4e23456167b9d5f8c1a29954107be9 --- /dev/null +++ b/chemprop/cli/utils/args.py @@ -0,0 +1,34 @@ +import functools + +__all__ = ["bounded"] + + +def bounded(lo: float | None = None, hi: float | None = None): + if lo is None and hi is None: + raise ValueError("No bounds provided!") + + def decorator(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + x = f(*args, **kwargs) + + if (lo is not None and hi is not None) and not lo <= x <= hi: + raise ValueError(f"Parsed value outside of range [{lo}, {hi}]! got: {x}") + if hi is not None and x > hi: + raise ValueError(f"Parsed value below {hi}! got: {x}") + if lo is not None and x < lo: + raise ValueError(f"Parsed value above {lo}]! got: {x}") + + return x + + return wrapper + + return decorator + + +def uppercase(x: str): + return x.upper() + + +def lowercase(x: str): + return x.lower() diff --git a/chemprop/cli/utils/command.py b/chemprop/cli/utils/command.py new file mode 100644 index 0000000000000000000000000000000000000000..4b4a7edd3373a7990df23ec16b6fff8843dd5e8a --- /dev/null +++ b/chemprop/cli/utils/command.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod +from argparse import ArgumentParser, _SubParsersAction, Namespace + + +class Subcommand(ABC): + COMMAND: str + HELP: str | None = None + + @classmethod + def add(cls, subparsers: _SubParsersAction, parents) -> ArgumentParser: + parser = subparsers.add_parser(cls.COMMAND, help=cls.HELP, parents=parents) + cls.add_args(parser).set_defaults(func=cls.func) + + return parser + + @classmethod + @abstractmethod + def add_args(cls, parser: ArgumentParser) -> ArgumentParser: + pass + + @classmethod + @abstractmethod + def func(cls, args: Namespace): + pass diff --git a/chemprop/cli/utils/parsing.py b/chemprop/cli/utils/parsing.py new file mode 100644 index 0000000000000000000000000000000000000000..c6dae8241b890fc0a8d1042b3d5b09d08b2d186e --- /dev/null +++ b/chemprop/cli/utils/parsing.py @@ -0,0 +1,380 @@ +import logging +from os import PathLike +from typing import Mapping, Sequence + +import numpy as np +import pandas as pd +from rdkit.Chem import Mol + +from chemprop.data.datapoints import MoleculeDatapoint, ReactionDatapoint +from chemprop.data.datasets import MoleculeDataset, ReactionDataset +from chemprop.featurizers.base import VectorFeaturizer +from chemprop.featurizers.molgraph import ( + CondensedGraphOfReactionFeaturizer, + SimpleMoleculeMolGraphFeaturizer, +) +from chemprop.featurizers.atom import get_multi_hot_atom_featurizer + +logger = logging.getLogger(__name__) + + +def parse_csv( + path: PathLike, + smiles_cols: Sequence[str] | None, + rxn_cols: Sequence[str] | None, + target_cols: Sequence[str] | None, + ignore_cols: Sequence[str] | None, + splits_col: str | None, + weight_col: str | None, + bounded: bool = False, + no_header_row: bool = False, +): + df = pd.read_csv(path, header=None if no_header_row else "infer", index_col=False) + + if smiles_cols is not None and rxn_cols is not None: + smiss = df[smiles_cols].T.values.tolist() + rxnss = df[rxn_cols].T.values.tolist() + input_cols = [*smiles_cols, *rxn_cols] + elif smiles_cols is not None and rxn_cols is None: + smiss = df[smiles_cols].T.values.tolist() + rxnss = None + input_cols = smiles_cols + elif smiles_cols is None and rxn_cols is not None: + smiss = None + rxnss = df[rxn_cols].T.values.tolist() + input_cols = rxn_cols + else: + smiss = df.iloc[:, [0]].T.values.tolist() + rxnss = None + input_cols = [df.columns[0]] + + if target_cols is None: + target_cols = list( + set(df.columns) + - set(input_cols) + - set(ignore_cols or []) + - set(splits_col or []) + - set(weight_col or []) + ) + + Y = df[target_cols] + weights = None if weight_col is None else df[weight_col].to_numpy(np.single) + + if bounded: + lt_mask = Y.applymap(lambda x: "<" in x).to_numpy() + gt_mask = Y.applymap(lambda x: ">" in x).to_numpy() + Y = Y.applymap(lambda x: x.strip("<").strip(">")).to_numpy(np.single) + else: + Y = Y.to_numpy(np.single) + lt_mask = None + gt_mask = None + + return smiss, rxnss, Y, weights, lt_mask, gt_mask + + +def get_column_names( + path: PathLike, + smiles_cols: Sequence[str] | None, + rxn_cols: Sequence[str] | None, + target_cols: Sequence[str] | None, + ignore_cols: Sequence[str] | None, + splits_col: str | None, + weight_col: str | None, + no_header_row: bool = False, +): + df = pd.read_csv(path, header=None if no_header_row else "infer", index_col=False) + + if no_header_row: + return ["SMILES"] + ["pred_" + str(i) for i in range((len(df.columns) - 1))] + + input_cols = (smiles_cols or []) + (rxn_cols or []) + + if len(input_cols) == 0: + input_cols = [df.columns[0]] + + if target_cols is None: + target_cols = list( + set(df.columns) + - set(input_cols) + - set(ignore_cols or []) + - set(splits_col or []) + - set(weight_col or []) + ) + + return input_cols + target_cols + + +def make_datapoints( + smiss: list[list[str]] | None, + rxnss: list[list[str]] | None, + Y: np.ndarray, + weights: np.ndarray | None, + lt_mask: np.ndarray | None, + gt_mask: np.ndarray | None, + X_d: np.ndarray | None, + V_fss: list[list[np.ndarray] | list[None]] | None, + E_fss: list[list[np.ndarray] | list[None]] | None, + V_dss: list[list[np.ndarray] | list[None]] | None, + features_generators: list[VectorFeaturizer[Mol]] | None, + keep_h: bool, + add_h: bool, +) -> tuple[list[list[MoleculeDatapoint]], list[list[ReactionDatapoint]]]: + """Make the :class:`MoleculeDatapoint`s and :class:`ReactionDatapoint`s for a given + dataset. + + Parameters + ---------- + smiss : list[list[str]] | None + a list of ``j`` lists of ``n`` SMILES strings, where ``j`` is the number of molecules per + datapoint and ``n`` is the number of datapoints. If ``None``, the corresponding list of + :class:`MoleculeDatapoint`\s will be empty. + rxnss : list[list[str]] | None + a list of ``k`` lists of ``n`` reaction SMILES strings, where ``k`` is the number of + reactions per datapoint. If ``None``, the corresponding list of :class:`ReactionDatapoint`\s + will be empty. + Y : np.ndarray + the target values of shape ``n x m``, where ``m`` is the number of targets + weights : np.ndarray | None + the weights of the datapoints to use in the loss function of shape ``n x m``. If ``None``, + the weights all default to 1. + lt_mask : np.ndarray | None + a boolean mask of shape ``n x m`` indicating whether the targets are less than inequality + targets. If ``None``, ``lt_mask`` for all datapoints will be ``None``. + gt_mask : np.ndarray | None + a boolean mask of shape ``n x m`` indicating whether the targets are greater than inequality + targets. If ``None``, ``gt_mask`` for all datapoints will be ``None``. + X_d : np.ndarray | None + the extra descriptors of shape ``n x p``, where ``p`` is the number of extra descriptors. If + ``None``, ``x_d`` for all datapoints will be ``None``. + V_fss : list[list[np.ndarray] | list[None]] | None + a list of ``j`` lists of ``n`` np.ndarrays each of shape ``v_jn x q_j``, where ``v_jn`` is + the number of atoms in the j-th molecule of the n-th datapoint and ``q_j`` is the number of + extra atom features used for the j-th molecules. Any of the ``j`` lists can be a list of + None values if the corresponding component does not use extra atom features. If ``None``, + ``V_f`` for all datapoints will be ``None``. + E_fss : list[list[np.ndarray] | list[None]] | None + a list of ``j`` lists of ``n`` np.ndarrays each of shape ``e_jn x r_j``, where ``e_jn`` is + the number of bonds in the j-th molecule of the n-th datapoint and ``r_j`` is the number of + extra bond features used for the j-th molecules. Any of the ``j`` lists can be a list of + None values if the corresponding component does not use extra bond features. If ``None``, + ``E_f`` for all datapoints will be ``None``. + V_dss : list[list[np.ndarray] | list[None]] | None + a list of ``j`` lists of ``n`` np.ndarrays each of shape ``v_jn x s_j``, where ``s_j`` is + the number of extra atom descriptors used for the j-th molecules. Any of the ``j`` lists can + be a list of None values if the corresponding component does not use extra atom features. If + ``None``, ``V_d`` for all datapoints will be ``None``. + features_generators : list[MoleculeFeaturizer] | None + a list of :class:`MoleculeFeaturizer` instances to generate additional molecule features to + use as extra descriptors + keep_h : bool + add_h : bool + + Returns + ------- + list[list[MoleculeDatapoint]] + a list of ``j`` lists of ``n`` :class:`MoleculeDatapoint`\s + list[list[ReactionDatapoint]] + a list of ``k`` lists of ``n`` :class:`ReactionDatapoint`\s + .. note:: + either ``j`` or ``k`` may be 0, in which case the corresponding list will be empty. + + Raises + ------ + ValueError + if both ``smiss`` and ``rxnss`` are ``None``. + if ``smiss`` and ``rxnss`` are both given and have different lengths. + """ + if smiss is None and rxnss is None: + raise ValueError("args 'smiss' and 'rnxss' were both `None`!") + elif rxnss is None: + N = len(smiss[0]) + rxnss = [] + elif smiss is None: + N = len(rxnss[0]) + smiss = [] + elif len(smiss[0]) != len(rxnss[0]): + raise ValueError( + f"args 'smiss' and 'rxnss' must have same length! got {len(smiss[0])} and {len(rxnss[0])}" + ) + else: + N = len(smiss[0]) + + weights = np.ones(N, dtype=np.single) if weights is None else weights + gt_mask = [None] * N if gt_mask is None else gt_mask + lt_mask = [None] * N if lt_mask is None else lt_mask + + n_mols = len(smiss) if smiss else 0 + X_d = [None] * N if X_d is None else X_d + V_fss = [[None] * N] * n_mols if V_fss is None else V_fss + E_fss = [[None] * N] * n_mols if E_fss is None else E_fss + V_dss = [[None] * N] * n_mols if V_dss is None else V_dss + + mol_data = [ + [ + MoleculeDatapoint.from_smi( + smis[i], + keep_h=keep_h, + add_h=add_h, + y=Y[i], + weight=weights[i], + gt_mask=gt_mask[i], + lt_mask=lt_mask[i], + x_d=X_d[i], + mfs=features_generators, + x_phase=None, + V_f=V_fss[mol_idx][i], + E_f=E_fss[mol_idx][i], + V_d=V_dss[mol_idx][i], + ) + for i in range(N) + ] + for mol_idx, smis in enumerate(smiss) + ] + rxn_data = [ + [ + ReactionDatapoint.from_smi( + rxns[i], + keep_h=keep_h, + add_h=add_h, + y=Y[i], + weight=weights[i], + gt_mask=gt_mask[i], + lt_mask=lt_mask[i], + x_d=X_d[i], + mfs=features_generators, + x_phase=None, + ) + for i in range(N) + ] + for rxn_idx, rxns in enumerate(rxnss) + ] + + return mol_data, rxn_data + + +def build_data_from_files( + p_data: PathLike, + no_header_row: bool, + smiles_cols: Sequence[str] | None, + rxn_cols: Sequence[str] | None, + target_cols: Sequence[str] | None, + ignore_cols: Sequence[str] | None, + splits_col: str | None, + weight_col: str | None, + bounded: bool, + p_descriptors: PathLike, + p_atom_feats: dict[int, PathLike], + p_bond_feats: dict[int, PathLike], + p_atom_descs: dict[int, PathLike], + **featurization_kwargs: Mapping, +) -> list[list[MoleculeDatapoint] | list[ReactionDatapoint]]: + smiss, rxnss, Y, weights, lt_mask, gt_mask = parse_csv( + p_data, + smiles_cols, + rxn_cols, + target_cols, + ignore_cols, + splits_col, + weight_col, + bounded, + no_header_row, + ) + n_molecules = len(smiss) if smiss is not None else 0 + n_datapoints = len(Y) + + X_ds = load_input_feats_and_descs(p_descriptors, None, None, feat_desc="X_d") + V_fss = load_input_feats_and_descs(p_atom_feats, n_molecules, n_datapoints, feat_desc="V_f") + E_fss = load_input_feats_and_descs(p_bond_feats, n_molecules, n_datapoints, feat_desc="E_f") + V_dss = load_input_feats_and_descs(p_atom_descs, n_molecules, n_datapoints, feat_desc="V_d") + + mol_data, rxn_data = make_datapoints( + smiss, + rxnss, + Y, + weights, + lt_mask, + gt_mask, + X_ds, + V_fss, + E_fss, + V_dss, + **featurization_kwargs, + ) + + return mol_data + rxn_data + + +def load_input_feats_and_descs( + paths: dict[int, PathLike] | PathLike, + n_molecules: int | None, + n_datapoints: int | None, + feat_desc: str, +): + if paths is None: + return None + + match feat_desc: + case "X_d": + path = paths + loaded_feature = np.load(path) + features = loaded_feature["arr_0"] + + case _: + for index in paths: + if index >= n_molecules: + raise ValueError( + f"For {n_molecules} molecules, atom/bond features/descriptors can only be specified for indices 0-{n_molecules - 1}! Got index {index}." + ) + + features = [] + for idx in range(n_molecules): + path = paths.get(idx, None) + + if path is not None: + loaded_feature = np.load(path) + loaded_feature = [ + loaded_feature[f"arr_{i}"] for i in range(len(loaded_feature)) + ] + else: + loaded_feature = [None] * n_datapoints + + features.append(loaded_feature) + return features + + +def make_dataset( + data: Sequence[MoleculeDatapoint] | Sequence[ReactionDatapoint], + reaction_mode: str, + multi_hot_atom_featurizer_mode: str = "V2", +) -> MoleculeDataset | ReactionDataset: + atom_featurizer = get_multi_hot_atom_featurizer(multi_hot_atom_featurizer_mode) + + if isinstance(data[0], MoleculeDatapoint): + extra_atom_fdim = data[0].V_f.shape[1] if data[0].V_f is not None else 0 + extra_bond_fdim = data[0].E_f.shape[1] if data[0].E_f is not None else 0 + featurizer = SimpleMoleculeMolGraphFeaturizer( + atom_featurizer=atom_featurizer, + extra_atom_fdim=extra_atom_fdim, + extra_bond_fdim=extra_bond_fdim, + ) + return MoleculeDataset(data, featurizer) + + featurizer = CondensedGraphOfReactionFeaturizer( + mode_=reaction_mode, atom_featurizer=atom_featurizer + ) + + return ReactionDataset(data, featurizer) + + +def parse_indices(idxs): + """Parses a string of indices into a list of integers. e.g. '0,1,2-4' -> [0, 1, 2, 3, 4]""" + if isinstance(idxs, str): + indices = [] + for idx in idxs.split(","): + if "-" in idx: + start, end = map(int, idx.split("-")) + indices.extend(range(start, end + 1)) + else: + indices.append(int(idx)) + return indices + return idxs diff --git a/chemprop/cli/utils/utils.py b/chemprop/cli/utils/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..28a4b4e2099d1ca3f5ee83b312da4c5c84f47eb4 --- /dev/null +++ b/chemprop/cli/utils/utils.py @@ -0,0 +1,76 @@ +from typing import Any, Type + +from chemprop.nn import loss, predictors + +__all__ = ["pop_attr"] + + +def pop_attr(o: object, attr: str, *args) -> Any | None: + """like ``pop()`` but for attribute maps""" + match len(args): + case 0: + return _pop_attr(o, attr) + case 1: + return _pop_attr_d(o, attr, args[0]) + case _: + raise TypeError(f"Expected at most 2 arguments! got: {len(args)}") + + +def _pop_attr(o: object, attr: str) -> Any: + val = getattr(o, attr) + delattr(o, attr) + + return val + + +def _pop_attr_d(o: object, attr: str, default: Any | None = None) -> Any | None: + try: + val = getattr(o, attr) + delattr(o, attr) + except AttributeError: + val = default + + return val + + +def validate_loss_function( + predictor_ffn: Type[predictors._FFNPredictorBase], criterion: Type[loss.LossFunction] +): + match predictor_ffn: + case predictors.RegressionFFN: + if criterion not in (loss.MSELoss, loss.BoundedMSELoss): + raise ValueError(f"Expected a regression loss function! got: {criterion.__name__}") + case predictors.MveFFN: + if criterion is not loss.MVELoss: + raise ValueError(f"Expected a MVE loss function! got: {criterion.__name__}") + case predictors.EvidentialFFN: + if criterion is not loss.EvidentialLoss: + raise ValueError(f"Expected an evidential loss function! got: {criterion.__name__}") + case predictors.BinaryClassificationFFN: + if criterion not in (loss.BCELoss, loss.BinaryMCCLoss): + raise ValueError( + f"Expected a binary classification loss function! got: {criterion.__name__}" + ) + case predictors.BinaryDirichletFFN: + if loss is not loss.BinaryDirichletLoss: + raise ValueError( + f"Expected a binary Dirichlet loss function! got: {criterion.__name__}" + ) + case predictors.MulticlassClassificationFFN: + if loss not in (loss.CrossEntropyLoss, loss.MulticlassMCCLoss): + raise ValueError( + f"Expected a multiclass classification loss function! got: {criterion.__name__}" + ) + case predictors.MulticlassDirichletFFN: + if loss is not loss.MulticlassDirichletLoss: + raise ValueError( + f"Expected a multiclass Dirichlet loss function! got: {criterion.__name__}" + ) + case predictors.SpectralFFN: + if loss not in (loss.SIDLoss, loss.WassersteinLoss): + raise ValueError(f"Expected a spectral loss function! got: {criterion.__name__}") + case _: + raise ValueError( + f"Unknown predictor function! got: {predictor_ffn}. " + f"Expected one of: {tuple(predictors.PredictorRegistry.values())}" + ) diff --git a/chemprop/conf.py b/chemprop/conf.py new file mode 100644 index 0000000000000000000000000000000000000000..921da6dbc7e6a8cbca365d50cb2751c521b6da43 --- /dev/null +++ b/chemprop/conf.py @@ -0,0 +1,7 @@ +"""Global configuration variables for chemprop""" + +from chemprop.featurizers.molgraph.molecule import SimpleMoleculeMolGraphFeaturizer + + +DEFAULT_ATOM_FDIM, DEFAULT_BOND_FDIM = SimpleMoleculeMolGraphFeaturizer().shape +DEFAULT_HIDDEN_DIM = 300 diff --git a/chemprop/data/__init__.py b/chemprop/data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8db6ad4704d8e0a074b259a94da2e9bf2e710221 --- /dev/null +++ b/chemprop/data/__init__.py @@ -0,0 +1,34 @@ +from .collate import BatchMolGraph, TrainingBatch, collate_batch, collate_multicomponent +from .dataloader import build_dataloader +from .datapoints import MoleculeDatapoint, ReactionDatapoint +from .datasets import ( + MoleculeDataset, + ReactionDataset, + Datum, + MulticomponentDataset, + MolGraphDataset, +) +from .molgraph import MolGraph +from .samplers import ClassBalanceSampler, SeededSampler +from .splitting import SplitType, make_split_indices, split_data_by_indices + +__all__ = [ + "BatchMolGraph", + "TrainingBatch", + "collate_batch", + "collate_multicomponent", + "build_dataloader", + "MoleculeDatapoint", + "ReactionDatapoint", + "MoleculeDataset", + "ReactionDataset", + "Datum", + "MulticomponentDataset", + "MolGraphDataset", + "MolGraph", + "ClassBalanceSampler", + "SeededSampler", + "SplitType", + "make_split_indices", + "split_data_by_indices", +] diff --git a/chemprop/data/collate.py b/chemprop/data/collate.py new file mode 100644 index 0000000000000000000000000000000000000000..21abdb157ebbefd1788fe77a7cb2a7fd254104b2 --- /dev/null +++ b/chemprop/data/collate.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass, field, InitVar +from typing import Iterable, NamedTuple, Sequence + +import numpy as np +import torch +from torch import Tensor + +from chemprop.data.datasets import Datum +from chemprop.data.molgraph import MolGraph + + +@dataclass(repr=False, eq=False, slots=True) +class BatchMolGraph: + """A :class:`BatchMolGraph` represents a batch of individual :class:`MolGraph`\s. + + It has all the attributes of a ``MolGraph`` with the addition of the ``batch`` attribute. This + class is intended for use with data loading, so it uses :obj:`~torch.Tensor`\s to store data + """ + + mgs: InitVar[Sequence[MolGraph]] + """A list of individual :class:`MolGraph`\s to be batched together""" + V: Tensor = field(init=False) + """the atom feature matrix""" + E: Tensor = field(init=False) + """the bond feature matrix""" + edge_index: Tensor = field(init=False) + """an tensor of shape ``2 x E`` containing the edges of the graph in COO format""" + rev_edge_index: Tensor = field(init=False) + """A tensor of shape ``E`` that maps from an edge index to the index of the source of the + reverse edge in the ``edge_index`` attribute.""" + batch: Tensor = field(init=False) + """the index of the parent :class:`MolGraph` in the batched graph""" + + __size: int = field(init=False) + + def __post_init__(self, mgs: Sequence[MolGraph]): + self.__size = len(mgs) + + Vs = [] + Es = [] + edge_indexes = [] + rev_edge_indexes = [] + batch_indexes = [] + + num_nodes = 0 + num_edges = 0 + for i, mg in enumerate(mgs): + Vs.append(mg.V) + Es.append(mg.E) + edge_indexes.append(mg.edge_index + num_nodes) + rev_edge_indexes.append(mg.rev_edge_index + num_edges) + batch_indexes.append([i] * len(mg.V)) + + num_nodes += mg.V.shape[0] + num_edges += mg.edge_index.shape[1] + + self.V = torch.from_numpy(np.concatenate(Vs)).float() + self.E = torch.from_numpy(np.concatenate(Es)).float() + self.edge_index = torch.from_numpy(np.hstack(edge_indexes)).long() + self.rev_edge_index = torch.from_numpy(np.concatenate(rev_edge_indexes)).long() + self.batch = torch.tensor(np.concatenate(batch_indexes)).long() + + def __len__(self) -> int: + """the number of individual :class:`MolGraph`\s in this batch""" + return self.__size + + def to(self, device: str | torch.device): + self.V = self.V.to(device) + self.E = self.E.to(device) + self.edge_index = self.edge_index.to(device) + self.rev_edge_index = self.rev_edge_index.to(device) + self.batch = self.batch.to(device) + + +class TrainingBatch(NamedTuple): + bmg: BatchMolGraph + V_d: Tensor | None + X_d: Tensor | None + Y: Tensor | None + w: Tensor + lt_mask: Tensor | None + gt_mask: Tensor | None + + +def collate_batch(batch: Iterable[Datum]) -> TrainingBatch: + mgs, V_ds, x_ds, ys, weights, lt_masks, gt_masks = zip(*batch) + + return TrainingBatch( + BatchMolGraph(mgs), + None if V_ds[0] is None else torch.from_numpy(np.concatenate(V_ds)).float(), + None if x_ds[0] is None else torch.from_numpy(np.array(x_ds)).float(), + None if ys[0] is None else torch.from_numpy(np.array(ys)).float(), + torch.tensor(weights, dtype=torch.float).unsqueeze(1), + None if lt_masks[0] is None else torch.from_numpy(np.array(lt_masks)), + None if gt_masks[0] is None else torch.from_numpy(np.array(gt_masks)), + ) + + +class MulticomponentTrainingBatch(NamedTuple): + bmgs: list[BatchMolGraph] + V_ds: list[Tensor | None] + X_d: Tensor | None + Y: Tensor | None + w: Tensor + lt_mask: Tensor | None + gt_mask: Tensor | None + + +def collate_multicomponent(batches: Iterable[Iterable[Datum]]) -> MulticomponentTrainingBatch: + tbs = [collate_batch(batch) for batch in zip(*batches)] + + return MulticomponentTrainingBatch( + [tb.bmg for tb in tbs], + [tb.V_d for tb in tbs], + tbs[0].X_d, + tbs[0].Y, + tbs[0].w, + tbs[0].lt_mask, + tbs[0].gt_mask, + ) diff --git a/chemprop/data/dataloader.py b/chemprop/data/dataloader.py new file mode 100644 index 0000000000000000000000000000000000000000..e4728344e8d8db894880f419ed48e7a854a1e103 --- /dev/null +++ b/chemprop/data/dataloader.py @@ -0,0 +1,69 @@ +import warnings + +from torch.utils.data import DataLoader + +from chemprop.data.collate import collate_batch, collate_multicomponent +from chemprop.data.datasets import MoleculeDataset, MulticomponentDataset, ReactionDataset +from chemprop.data.samplers import ClassBalanceSampler, SeededSampler + + +def build_dataloader( + dataset: MoleculeDataset | ReactionDataset | MulticomponentDataset, + batch_size: int = 64, + num_workers: int = 0, + class_balance: bool = False, + seed: int | None = None, + shuffle: bool = True, + **kwargs, +): + """Return a :obj:`~torch.utils.data.DataLoader` for :class:`MolGraphDataset`\s + + Parameters + ---------- + dataset : MoleculeDataset | ReactionDataset | MulticomponentDataset + The dataset containing the molecules or reactions to load. + batch_size : int, default=64 + the batch size to load. + num_workers : int, default=0 + the number of workers used to build batches. + class_balance : bool, default=False + Whether to perform class balancing (i.e., use an equal number of positive and negative + molecules). Class balance is only available for single task classification datasets. Set + shuffle to True in order to get a random subset of the larger class. + seed : int, default=None + the random seed to use for shuffling (only used when `shuffle` is `True`). + shuffle : bool, default=False + whether to shuffle the data during sampling. + """ + + if class_balance: + sampler = ClassBalanceSampler(dataset.Y, seed, shuffle) + elif shuffle and seed is not None: + sampler = SeededSampler(len(dataset), seed) + else: + sampler = None + + if isinstance(dataset, MulticomponentDataset): + collate_fn = collate_multicomponent + else: + collate_fn = collate_batch + + if len(dataset) % batch_size == 1: + warnings.warn( + f"Dropping last batch of size 1 to avoid issues with batch normalization \ +(dataset size = {len(dataset)}, batch_size = {batch_size})" + ) + drop_last = True + else: + drop_last = False + + return DataLoader( + dataset, + batch_size, + sampler is None and shuffle, + sampler, + num_workers=num_workers, + collate_fn=collate_fn, + drop_last=drop_last, + **kwargs, + ) diff --git a/chemprop/data/datapoints.py b/chemprop/data/datapoints.py new file mode 100644 index 0000000000000000000000000000000000000000..fef5e81d3326f16a565cfd08cffeebaccae1d75a --- /dev/null +++ b/chemprop/data/datapoints.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from dataclasses import InitVar, dataclass + +import numpy as np +from rdkit.Chem import AllChem as Chem + +from chemprop.featurizers import Featurizer +from chemprop.utils import make_mol + +MoleculeFeaturizer = Featurizer[Chem.Mol, np.ndarray] + + +@dataclass(slots=True) +class _DatapointMixin: + """A mixin class for both molecule- and reaction- and multicomponent-type data""" + + y: np.ndarray | None = None + """the targets for the molecule with unknown targets indicated by `nan`s""" + weight: float = 1.0 + """the weight of this datapoint for the loss calculation.""" + gt_mask: np.ndarray | None = None + """Indicates whether the targets are an inequality regression target of the form `x`""" + x_d: np.ndarray | None = None + """A vector of length ``d_f`` containing additional features (e.g., Morgan fingerprint) that + will be concatenated to the global representation *after* aggregation""" + mfs: InitVar[list[MoleculeFeaturizer] | None] = None + """A list of molecule featurizers to use""" + x_phase: list[float] = None + """A one-hot vector indicating the phase of the data, as used in spectra data.""" + name: str | None = None + """A string identifier for the datapoint.""" + + def __post_init__(self, mfs: list[MoleculeFeaturizer] | None): + if self.x_d is not None and mfs is not None: + raise ValueError("Cannot provide both loaded features and molecular featurizers!") + + if mfs is not None: + self.x_d = self.calc_features(mfs) + + NAN_TOKEN = 0 + if self.x_d is not None: + self.x_d[np.isnan(self.x_d)] = NAN_TOKEN + + @property + def t(self) -> int | None: + return len(self.y) if self.y is not None else None + + +@dataclass +class _MoleculeDatapointMixin: + mol: Chem.Mol + """the molecule associated with this datapoint""" + + @classmethod + def from_smi( + cls, smi: str, *args, keep_h: bool = False, add_h: bool = False, **kwargs + ) -> _MoleculeDatapointMixin: + mol = make_mol(smi, keep_h, add_h) + + kwargs["name"] = smi if "name" not in kwargs else kwargs["name"] + + return cls(mol, *args, **kwargs) + + +@dataclass +class MoleculeDatapoint(_DatapointMixin, _MoleculeDatapointMixin): + """A :class:`MoleculeDatapoint` contains a single molecule and its associated features and targets.""" + + V_f: np.ndarray | None = None + """a numpy array of shape ``V x d_vf``, where ``V`` is the number of atoms in the molecule, and + ``d_vf`` is the number of additional features that will be concatenated to atom-level features + *before* message passing""" + E_f: np.ndarray | None = None + """A numpy array of shape ``E x d_ef``, where ``E`` is the number of bonds in the molecule, and + ``d_ef`` is the number of additional features containing additional features that will be + concatenated to bond-level features *before* message passing""" + V_d: np.ndarray | None = None + """A numpy array of shape ``V x d_vd``, where ``V`` is the number of atoms in the molecule, and + ``d_vd`` is the number of additional descriptors that will be concatenated to atom-level + descriptors *after* message passing""" + + def __post_init__(self, mfs: list[MoleculeFeaturizer] | None): + if self.mol is None: + raise ValueError("Input molecule was `None`!") + + NAN_TOKEN = 0 + + if self.V_f is not None: + self.V_f[np.isnan(self.V_f)] = NAN_TOKEN + if self.E_f is not None: + self.E_f[np.isnan(self.E_f)] = NAN_TOKEN + if self.V_d is not None: + self.V_d[np.isnan(self.V_d)] = NAN_TOKEN + + super().__post_init__(mfs) + + def __len__(self) -> int: + return 1 + + def calc_features(self, mfs: list[MoleculeFeaturizer]) -> np.ndarray: + if self.mol.GetNumHeavyAtoms() == 0: + return np.zeros(sum(len(mf) for mf in mfs)) + + return np.hstack([mf(self.mol) for mf in mfs]) + + +@dataclass +class _ReactionDatapointMixin: + rct: Chem.Mol + """the reactant associated with this datapoint""" + pdt: Chem.Mol + """the product associated with this datapoint""" + + @classmethod + def from_smi( + cls, + rxn_or_smis: str | tuple[str, str], + *args, + keep_h: bool = False, + add_h: bool = False, + **kwargs, + ) -> _ReactionDatapointMixin: + match rxn_or_smis: + case str(): + rct_smi, agt_smi, pdt_smi = rxn_or_smis.split(">") + rct_smi = f"{rct_smi}.{agt_smi}" if agt_smi else rct_smi + name = rxn_or_smis + case tuple(): + rct_smi, pdt_smi = rxn_or_smis + name = ">>".join(rxn_or_smis) + case _: + raise TypeError( + "Must provide either a reaction SMARTS string or a tuple of reactant and product SMILES strings!" + ) + + rct = make_mol(rct_smi, keep_h, add_h) + pdt = make_mol(pdt_smi, keep_h, add_h) + + kwargs["name"] = name if "name" not in kwargs else kwargs["name"] + + return cls(rct, pdt, *args, **kwargs) + + +@dataclass +class ReactionDatapoint(_DatapointMixin, _ReactionDatapointMixin): + """A :class:`ReactionDatapoint` contains a single reaction and its associated features and targets.""" + + def __post_init__(self, mfs: list[MoleculeFeaturizer] | None): + if self.rct is None: + raise ValueError("Reactant cannot be `None`!") + if self.pdt is None: + raise ValueError("Product cannot be `None`!") + + return super().__post_init__(mfs) + + def __len__(self) -> int: + return 2 + + def calc_features(self, mfs: list[MoleculeFeaturizer]) -> np.ndarray: + x_ds = [ + mf(mol) if mol.GetNumHeavyAtoms() > 0 else np.zeros(len(mf)) + for mf in mfs + for mol in [self.rct, self.pdt] + ] + + return np.hstack(x_ds) diff --git a/chemprop/data/datasets.py b/chemprop/data/datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..1d948ed4f02e4aa47b483e675311ec233beb5fa2 --- /dev/null +++ b/chemprop/data/datasets.py @@ -0,0 +1,459 @@ +from dataclasses import dataclass, field +from functools import cached_property +from typing import NamedTuple, TypeAlias + +import numpy as np +from numpy.typing import ArrayLike +from rdkit import Chem +from rdkit.Chem import Mol +from sklearn.preprocessing import StandardScaler +from torch.utils.data import Dataset + +from chemprop.types import Rxn +from chemprop.data.datapoints import MoleculeDatapoint, ReactionDatapoint +from chemprop.data.molgraph import MolGraph +from chemprop.featurizers.base import Featurizer +from chemprop.featurizers.molgraph.cache import MolGraphCache, MolGraphCacheOnTheFly +from chemprop.featurizers.molgraph import SimpleMoleculeMolGraphFeaturizer, CGRFeaturizer + + +class Datum(NamedTuple): + """a singular training data point""" + + mg: MolGraph + V_d: np.ndarray | None + x_d: np.ndarray | None + y: np.ndarray | None + weight: float + lt_mask: np.ndarray | None + gt_mask: np.ndarray | None + + +MolGraphDataset: TypeAlias = Dataset[Datum] + + +class _MolGraphDatasetMixin: + def __len__(self) -> int: + return len(self.data) + + @cached_property + def _Y(self) -> np.ndarray: + """the raw targets of the dataset""" + return np.array([d.y for d in self.data], float) + + @property + def Y(self) -> np.ndarray: + """the (scaled) targets of the dataset""" + return self.__Y + + @Y.setter + def Y(self, Y: ArrayLike): + self._validate_attribute(Y, "targets") + + self.__Y = np.array(Y, float) + + @cached_property + def _X_d(self) -> np.ndarray: + """the raw extra descriptors of the dataset""" + return np.array([d.x_d for d in self.data]) + + @property + def X_d(self) -> np.ndarray: + """the (scaled) extra descriptors of the dataset""" + return self.__X_d + + @X_d.setter + def X_d(self, X_d: ArrayLike): + self._validate_attribute(X_d, "extra descriptors") + + self.__X_d = np.array(X_d) + + @property + def weights(self) -> np.ndarray: + return np.array([d.weight for d in self.data]) + + @property + def gt_mask(self) -> np.ndarray: + return np.array([d.gt_mask for d in self.data]) + + @property + def lt_mask(self) -> np.ndarray: + return np.array([d.lt_mask for d in self.data]) + + @property + def t(self) -> int | None: + return self.data[0].t if len(self.data) > 0 else None + + @property + def d_xd(self) -> int: + """the extra molecule descriptor dimension, if any""" + return 0 if self.X_d[0] is None else self.X_d.shape[1] + + @property + def names(self) -> list[str]: + return [d.name for d in self.data] + + def normalize_targets(self, scaler: StandardScaler | None = None) -> StandardScaler: + """Normalizes the targets of this dataset using a :obj:`StandardScaler` + + The :obj:`StandardScaler` subtracts the mean and divides by the standard deviation for + each task independently. NOTE: This should only be used for regression datasets. + + Returns + ------- + StandardScaler + a scaler fit to the targets. + """ + + if scaler is None: + scaler = StandardScaler().fit(self._Y) + + self.Y = scaler.transform(self._Y) + + return scaler + + def normalize_inputs( + self, key: str = "X_d", scaler: StandardScaler | None = None + ) -> StandardScaler: + VALID_KEYS = {"X_d"} + if key not in VALID_KEYS: + raise ValueError(f"Invalid feature key! got: {key}. expected one of: {VALID_KEYS}") + + X = self.X_d if self.X_d[0] is not None else None + + if X is None: + return scaler + + if scaler is None: + scaler = StandardScaler().fit(X) + + self.X_d = scaler.transform(X) + + return scaler + + def reset(self): + """Reset the atom and bond features; atom and extra descriptors; and targets of each + datapoint to their initial, unnormalized values.""" + self.__Y = self._Y + self.__X_d = self._X_d + + def _validate_attribute(self, X: np.ndarray, label: str): + if not len(self.data) == len(X): + raise ValueError( + f"number of molecules ({len(self.data)}) and {label} ({len(X)}) " + "must have same length!" + ) + + +@dataclass +class MoleculeDataset(_MolGraphDatasetMixin, MolGraphDataset): + """A :class:`MoleculeDataset` composed of :class:`MoleculeDatapoint`\s + + A :class:`MoleculeDataset` produces featurized data for input to a + :class:`MPNN` model. Typically, data featurization is performed on-the-fly + and parallelized across multiple workers via the :class:`~torch.utils.data + DataLoader` class. However, for small datasets, it may be more efficient to + featurize the data in advance and cache the results. This can be done by + setting ``MoleculeDataset.cache=True``. + + Parameters + ---------- + data : Iterable[MoleculeDatapoint] + the data from which to create a dataset + featurizer : MoleculeFeaturizer + the featurizer with which to generate MolGraphs of the molecules + """ + + data: list[MoleculeDatapoint] + featurizer: Featurizer[Mol, MolGraph] = field(default_factory=SimpleMoleculeMolGraphFeaturizer) + + def __post_init__(self): + if self.data is None: + raise ValueError("Data cannot be None!") + + self.reset() + self.cache = False + + def __getitem__(self, idx: int) -> Datum: + d = self.data[idx] + mg = self.mg_cache[idx] + + return Datum(mg, self.V_ds[idx], self.X_d[idx], self.Y[idx], d.weight, d.lt_mask, d.gt_mask) + + @property + def cache(self) -> bool: + return self.__cache + + @cache.setter + def cache(self, cache: bool = False): + self.__cache = cache + self._init_cache() + + def _init_cache(self): + """initialize the cache""" + self.mg_cache = (MolGraphCache if self.cache else MolGraphCacheOnTheFly)( + self.mols, self.V_fs, self.E_fs, self.featurizer + ) + + @property + def smiles(self) -> list[str]: + """the SMILES strings associated with the dataset""" + return [Chem.MolToSmiles(d.mol) for d in self.data] + + @property + def mols(self) -> list[Chem.Mol]: + """the molecules associated with the dataset""" + return [d.mol for d in self.data] + + @property + def _V_fs(self) -> list[np.ndarray]: + """the raw atom features of the dataset""" + return [d.V_f for d in self.data] + + @property + def V_fs(self) -> list[np.ndarray]: + """the (scaled) atom descriptors of the dataset""" + return self.__V_fs + + @V_fs.setter + def V_fs(self, V_fs: list[np.ndarray]): + """the (scaled) atom features of the dataset""" + self._validate_attribute(V_fs, "atom features") + + self.__V_fs = V_fs + self._init_cache() + + @property + def _E_fs(self) -> list[np.ndarray]: + """the raw bond features of the dataset""" + return [d.E_f for d in self.data] + + @property + def E_fs(self) -> list[np.ndarray]: + """the (scaled) bond features of the dataset""" + return self.__E_fs + + @E_fs.setter + def E_fs(self, E_fs: list[np.ndarray]): + self._validate_attribute(E_fs, "bond features") + + self.__E_fs = E_fs + self._init_cache() + + @property + def _V_ds(self) -> list[np.ndarray]: + """the raw atom descriptors of the dataset""" + return [d.V_d for d in self.data] + + @property + def V_ds(self) -> list[np.ndarray]: + """the (scaled) atom descriptors of the dataset""" + return self.__V_ds + + @V_ds.setter + def V_ds(self, V_ds: list[np.ndarray]): + self._validate_attribute(V_ds, "atom descriptors") + + self.__V_ds = V_ds + + @property + def d_vf(self) -> int: + """the extra atom feature dimension, if any""" + return 0 if self.V_fs[0] is None else self.V_fs[0].shape[1] + + @property + def d_ef(self) -> int: + """the extra bond feature dimension, if any""" + return 0 if self.E_fs[0] is None else self.E_fs[0].shape[1] + + @property + def d_vd(self) -> int: + """the extra atom descriptor dimension, if any""" + return 0 if self.V_ds[0] is None else self.V_ds[0].shape[1] + + def normalize_inputs( + self, key: str = "X_d", scaler: StandardScaler | None = None + ) -> StandardScaler: + VALID_KEYS = {"X_d", "V_f", "E_f", "V_d"} + + match key: + case "X_d": + X = None if self.d_xd == 0 else self.X_d + case "V_f": + X = None if self.d_vf == 0 else np.concatenate(self.V_fs, axis=0) + case "E_f": + X = None if self.d_ef == 0 else np.concatenate(self.E_fs, axis=0) + case "V_d": + X = None if self.d_vd == 0 else np.concatenate(self.V_ds, axis=0) + case _: + raise ValueError(f"Invalid feature key! got: {key}. expected one of: {VALID_KEYS}") + + if X is None: + return scaler + + if scaler is None: + scaler = StandardScaler().fit(X) + + match key: + case "X_d": + self.X_d = scaler.transform(X) + case "V_f": + self.V_fs = [scaler.transform(V_f) if V_f.size > 0 else V_f for V_f in self.V_fs] + case "E_f": + self.E_fs = [scaler.transform(E_f) if E_f.size > 0 else E_f for E_f in self.E_fs] + case "V_d": + self.V_ds = [scaler.transform(V_d) if V_d.size > 0 else V_d for V_d in self.V_ds] + case _: + raise RuntimeError("unreachable code reached!") + + return scaler + + def reset(self): + """Reset the atom and bond features; atom and extra descriptors; and targets of each + datapoint to their initial, unnormalized values.""" + super().reset() + self.__V_fs = self._V_fs + self.__E_fs = self._E_fs + self.__V_ds = self._V_ds + + +@dataclass +class ReactionDataset(_MolGraphDatasetMixin, MolGraphDataset): + """A :class:`ReactionDataset` composed of :class:`ReactionDatapoint`\s + + .. note:: + The featurized data provided by this class may be cached, simlar to a + :class:`MoleculeDataset`. To enable the cache, set ``ReactionDataset + cache=True``. + """ + + data: list[ReactionDatapoint] + """the dataset from which to load""" + featurizer: Featurizer[Rxn, MolGraph] = field(default_factory=CGRFeaturizer) + """the featurizer with which to generate MolGraphs of the input""" + + def __post_init__(self): + if self.data is None: + raise ValueError("Data cannot be None!") + + self.reset() + self.cache = False + + @property + def cache(self) -> bool: + return self.__cache + + @cache.setter + def cache(self, cache: bool = False): + self.__cache = cache + self.mg_cache = (MolGraphCache if cache else MolGraphCacheOnTheFly)( + self.mols, [None] * len(self), [None] * len(self), self.featurizer + ) + + def __getitem__(self, idx: int) -> Datum: + d = self.data[idx] + mg = self.mg_cache[idx] + + return Datum(mg, None, self.X_d[idx], self.Y[idx], d.weight, d.lt_mask, d.gt_mask) + + @property + def smiles(self) -> list[tuple]: + return [(Chem.MolToSmiles(d.rct), Chem.MolToSmiles(d.pdt)) for d in self.data] + + @property + def mols(self) -> list[Rxn]: + return [(d.rct, d.pdt) for d in self.data] + + @property + def d_vf(self) -> int: + return 0 + + @property + def d_ef(self) -> int: + return 0 + + @property + def d_vd(self) -> int: + return 0 + + +@dataclass(repr=False, eq=False) +class MulticomponentDataset(_MolGraphDatasetMixin, Dataset): + """A :class:`MulticomponentDataset` is a :class:`Dataset` composed of parallel + :class:`MoleculeDatasets` and :class:`ReactionDataset`\s""" + + datasets: list[MoleculeDataset | ReactionDataset] + """the parallel datasets""" + + def __post_init__(self): + sizes = [len(dset) for dset in self.datasets] + if not all(sizes[0] == size for size in sizes[1:]): + raise ValueError(f"Datasets must have all same length! got: {sizes}") + + def __len__(self) -> int: + return len(self.datasets[0]) + + @property + def n_components(self) -> int: + return len(self.datasets) + + def __getitem__(self, idx: int) -> list[Datum]: + return [dset[idx] for dset in self.datasets] + + @property + def smiles(self) -> list[list[str]]: + return list(zip(*[dset.smiles for dset in self.datasets])) + + @property + def names(self) -> list[list[str]]: + return list(zip(*[dset.names for dset in self.datasets])) + + @property + def mols(self) -> list[list[Chem.Mol]]: + return list(zip(*[dset.mols for dset in self.datasets])) + + def normalize_targets(self, scaler: StandardScaler | None = None) -> StandardScaler: + return self.datasets[0].normalize_targets(scaler) + + def normalize_inputs( + self, key: str = "X_d", scaler: list[StandardScaler] | None = None + ) -> list[StandardScaler]: + RXN_VALID_KEYS = {"X_d"} + match scaler: + case None: + return [ + dset.normalize_inputs(key) + if isinstance(dset, MoleculeDataset) or key in RXN_VALID_KEYS + else None + for dset in self.datasets + ] + case _: + assert len(scaler) == len( + self.datasets + ), "Number of scalers must match number of datasets!" + + return [ + dset.normalize_inputs(key, s) + if isinstance(dset, MoleculeDataset) or key in RXN_VALID_KEYS + else None + for dset, s in zip(self.datasets, scaler) + ] + + def reset(self): + return [dset.reset() for dset in self.datasets] + + @property + def d_xd(self) -> list[int]: + return self.datasets[0].d_xd + + @property + def d_vf(self) -> list[int]: + return sum(dset.d_vf for dset in self.datasets) + + @property + def d_ef(self) -> list[int]: + return sum(dset.d_ef for dset in self.datasets) + + @property + def d_vd(self) -> list[int]: + return sum(dset.d_vd for dset in self.datasets) diff --git a/chemprop/data/molgraph.py b/chemprop/data/molgraph.py new file mode 100644 index 0000000000000000000000000000000000000000..2b4e6b4e19ad6fee2e28ddad171c4ac8b4f3bbe8 --- /dev/null +++ b/chemprop/data/molgraph.py @@ -0,0 +1,16 @@ +from typing import NamedTuple + +import numpy as np + + +class MolGraph(NamedTuple): + """A :class:`MolGraph` represents the graph featurization of a molecule.""" + + V: np.ndarray + """an array of shape ``V x d_v`` containing the atom features of the molecule""" + E: np.ndarray + """an array of shape ``E x d_e`` containing the bond features of the molecule""" + edge_index: np.ndarray + """an array of shape ``2 x E`` containing the edges of the graph in COO format""" + rev_edge_index: np.ndarray + """A array of shape ``E`` that maps from an edge index to the index of the source of the reverse edge in :attr:`edge_index` attribute.""" diff --git a/chemprop/data/samplers.py b/chemprop/data/samplers.py new file mode 100644 index 0000000000000000000000000000000000000000..d19855b0e56c36cd01a02632922dd029b1798a76 --- /dev/null +++ b/chemprop/data/samplers.py @@ -0,0 +1,66 @@ +from itertools import chain +from typing import Iterator, Optional + +import numpy as np +from torch.utils.data import Sampler + + +class SeededSampler(Sampler): + """A :class`SeededSampler` is a class for iterating through a dataset in a randomly seeded + fashion""" + + def __init__(self, N: int, seed: int): + if seed is None: + raise ValueError("arg 'seed' was `None`! A SeededSampler must be seeded!") + + self.idxs = np.arange(N) + self.rg = np.random.default_rng(seed) + + def __iter__(self) -> Iterator[int]: + """an iterator over indices to sample.""" + self.rg.shuffle(self.idxs) + + return iter(self.idxs) + + def __len__(self) -> int: + """the number of indices that will be sampled.""" + return len(self.idxs) + + +class ClassBalanceSampler(Sampler): + """A :class:`ClassBalanceSampler` samples data from a :class:`MolGraphDataset` such that + positive and negative classes are equally sampled + + Parameters + ---------- + dataset : MolGraphDataset + the dataset from which to sample + seed : int + the random seed to use for shuffling (only used when `shuffle` is `True`) + shuffle : bool, default=False + whether to shuffle the data during sampling + """ + + def __init__(self, Y: np.ndarray, seed: Optional[int] = None, shuffle: bool = False): + self.shuffle = shuffle + self.rg = np.random.default_rng(seed) + + idxs = np.arange(len(Y)) + actives = Y.any(1) + + self.pos_idxs = idxs[actives] + self.neg_idxs = idxs[~actives] + + self.length = 2 * min(len(self.pos_idxs), len(self.neg_idxs)) + + def __iter__(self) -> Iterator[int]: + """an iterator over indices to sample.""" + if self.shuffle: + self.rg.shuffle(self.pos_idxs) + self.rg.shuffle(self.neg_idxs) + + return chain(*zip(self.pos_idxs, self.neg_idxs)) + + def __len__(self) -> int: + """the number of indices that will be sampled.""" + return self.length diff --git a/chemprop/data/splitting.py b/chemprop/data/splitting.py new file mode 100644 index 0000000000000000000000000000000000000000..7319ab1fdc75bbc21966cba5a113738a62c697b2 --- /dev/null +++ b/chemprop/data/splitting.py @@ -0,0 +1,257 @@ +import copy +import logging +from enum import auto +from collections.abc import Sequence, Iterable +import numpy as np +from astartes import train_test_split, train_val_test_split +from astartes.molecules import train_test_split_molecules, train_val_test_split_molecules +from rdkit import Chem + +from chemprop.data.datapoints import MoleculeDatapoint, ReactionDatapoint +from chemprop.utils.utils import EnumMapping + +logger = logging.getLogger(__name__) + +Datapoints = Sequence[MoleculeDatapoint] | Sequence[ReactionDatapoint] +MulticomponentDatapoints = Sequence[Datapoints] + + +class SplitType(EnumMapping): + CV_NO_VAL = auto() + CV = auto() + SCAFFOLD_BALANCED = auto() + RANDOM_WITH_REPEATED_SMILES = auto() + RANDOM = auto() + KENNARD_STONE = auto() + KMEANS = auto() + + +def make_split_indices( + mols: Sequence[Chem.Mol], + split: SplitType | str = "random", + sizes: tuple[float, float, float] = (0.8, 0.1, 0.1), + seed: int = 0, + num_folds: int = 1, +): + """Splits data into training, validation, and test splits. + + Parameters + ---------- + mols : Sequence[Chem.Mol] + Sequence of RDKit molecules to use for structure based splitting + split : SplitType | str, optional + Split type, one of ~chemprop.data.utils.SplitType, by default "random" + sizes : tuple[float, float, float], optional + 3-tuple with the proportions of data in the train, validation, and test sets, by default + (0.8, 0.1, 0.1). Set the middle value to 0 for a two way split. + seed : int, optional + The random seed passed to astartes, by default 0 + num_folds : int, optional + Number of folds to create (only needed for "cv" and "cv-no-test"), by default 1 + + Returns + ------- + tuple[list[int], list[int], list[int]] | tuple[list[list[int], ...], list[list[int], ...], list[list[int], ...]] + A tuple of list of indices corresponding to the train, validation, and test splits of the + data. If the split type is "cv" or "cv-no-test", returns a tuple of lists of lists of + indices corresponding to the train, validation, and test splits of each fold. + .. important:: + validation may or may not be present + + Raises + ------ + ValueError + Requested split sizes tuple not of length 3 + ValueError + Innapropriate number of folds requested + ValueError + Unsupported split method requested + """ + if (num_splits := len(sizes)) != 3: + raise ValueError( + f"Specify sizes for train, validation, and test (got {num_splits} values)." + ) + # typically include a validation set + include_val = True + split_fun = train_val_test_split + mol_split_fun = train_val_test_split_molecules + # default sampling arguments for astartes sampler + astartes_kwargs = dict( + train_size=sizes[0], test_size=sizes[2], return_indices=True, random_state=seed + ) + # if no validation set, reassign the splitting functions + if sizes[1] == 0.0: + include_val = False + split_fun = train_test_split + mol_split_fun = train_test_split_molecules + else: + astartes_kwargs["val_size"] = sizes[1] + + n_datapoints = len(mols) + train, val, test = None, None, None + match SplitType.get(split): + case SplitType.CV_NO_VAL | SplitType.CV: + min_folds = 2 if SplitType.get(split) == SplitType.CV_NO_VAL else 3 + if not (min_folds <= num_folds <= n_datapoints): + raise ValueError( + f"invalid number of folds requested! got: {num_folds}, but expected between " + f"{min_folds} and {n_datapoints} (i.e., number of datapoints), inclusive, " + f"for split type: {repr(split)}" + ) + + # returns nested lists of indices + train, val, test = [], [], [] + random = np.random.default_rng(seed) + + indices = np.tile(np.arange(num_folds), 1 + n_datapoints // num_folds)[:n_datapoints] + random.shuffle(indices) + + for fold_idx in range(num_folds): + test_index = fold_idx + val_index = (fold_idx + 1) % num_folds + + if split != SplitType.CV_NO_VAL: + i_val = np.where(indices == val_index)[0] + i_test = np.where(indices == test_index)[0] + i_train = np.where((indices != val_index) & (indices != test_index))[0] + else: + i_val = [] + i_test = np.where(indices == test_index)[0] + i_train = np.where(indices != test_index)[0] + + train.append(i_train) + val.append(i_val) + test.append(i_test) + + case SplitType.SCAFFOLD_BALANCED: + mols_without_atommaps = [] + for mol in mols: + copied_mol = copy.deepcopy(mol) + for atom in copied_mol.GetAtoms(): + atom.SetAtomMapNum(0) + mols_without_atommaps.append(copied_mol) + result = mol_split_fun( + np.array(mols_without_atommaps), sampler="scaffold", **astartes_kwargs + ) + train, val, test = _unpack_astartes_result(result, include_val) + + # Use to constrain data with the same smiles go in the same split. + case SplitType.RANDOM_WITH_REPEATED_SMILES: + # get two arrays: one of all the smiles strings, one of just the unique + all_smiles = np.array([Chem.MolToSmiles(mol) for mol in mols]) + unique_smiles = np.unique(all_smiles) + + # save a mapping of smiles -> all the indices that it appeared at + smiles_indices = {} + for smiles in unique_smiles: + smiles_indices[smiles] = np.where(all_smiles == smiles)[0].tolist() + + # randomly split the unique smiles + result = split_fun(np.arange(len(unique_smiles)), sampler="random", **astartes_kwargs) + train_idxs, val_idxs, test_idxs = _unpack_astartes_result(result, include_val) + + # convert these to the 'actual' indices from the original list using the dict we made + train = sum((smiles_indices[unique_smiles[i]] for i in train_idxs), []) + val = sum((smiles_indices[unique_smiles[j]] for j in val_idxs), []) + test = sum((smiles_indices[unique_smiles[k]] for k in test_idxs), []) + + case SplitType.RANDOM: + result = split_fun(np.arange(n_datapoints), sampler="random", **astartes_kwargs) + train, val, test = _unpack_astartes_result(result, include_val) + + case SplitType.KENNARD_STONE: + result = mol_split_fun( + np.array(mols), + sampler="kennard_stone", + hopts=dict(metric="jaccard"), + fingerprint="morgan_fingerprint", + fprints_hopts=dict(n_bits=2048), + **astartes_kwargs, + ) + train, val, test = _unpack_astartes_result(result, include_val) + + case SplitType.KMEANS: + result = mol_split_fun( + np.array(mols), + sampler="kmeans", + hopts=dict(metric="jaccard"), + fingerprint="morgan_fingerprint", + fprints_hopts=dict(n_bits=2048), + **astartes_kwargs, + ) + train, val, test = _unpack_astartes_result(result, include_val) + + case _: + raise RuntimeError("Unreachable code reached!") + + return train, val, test + + +def _unpack_astartes_result( + result: tuple, include_val: bool +) -> tuple[list[list[int]], list[list[int]], list[list[int]]]: + """Helper function to partition input data based on output of astartes sampler + + Parameters + ----------- + result: tuple + Output from call to astartes containing the split indices + include_val: bool + True if a validation set is included, False otherwise. + + Returns + --------- + train: list[int] + val: list[int] + .. important:: + validation possibly empty + test: list[int] + """ + train_idxs, val_idxs, test_idxs = [], [], [] + # astartes returns a set of lists containing the data, clusters (if applicable) + # and indices (always last), so we pull out the indices + if include_val: + train_idxs, val_idxs, test_idxs = result[-3], result[-2], result[-1] + else: + train_idxs, test_idxs = result[-2], result[-1] + return list(train_idxs), list(val_idxs), list(test_idxs) + + +def split_data_by_indices( + data: Datapoints | MulticomponentDatapoints, + train_indices: Iterable[Iterable[int]] | Iterable[int] | None = None, + val_indices: Iterable[Iterable[int]] | Iterable[int] | None = None, + test_indices: Iterable[Iterable[int]] | Iterable[int] | None = None, +): + """Splits data into training, validation, and test groups based on split indices given.""" + + train_data = _splitter_helper(data, train_indices) if train_indices is not None else None + val_data = _splitter_helper(data, val_indices) if val_indices is not None else None + test_data = _splitter_helper(data, test_indices) if test_indices is not None else None + + return train_data, val_data, test_data + + +def _splitter_helper(data, indices): + nested_component = not isinstance(data[0], (MoleculeDatapoint, ReactionDatapoint)) + nested_split = isinstance(indices[0], Iterable) + + match (nested_component, nested_split): + case (False, False): + datapoints = data + idxs = indices + return [datapoints[idx] for idx in idxs] + case (False, True): + datapoints = data + idxss = indices + return [[datapoints[idx] for idx in idxs] for idxs in idxss] + case (True, False): + datapointss = data + idxs = indices + return [[datapoints[idx] for idx in idxs] for datapoints in datapointss] + case (True, True): + datapointss = data + idxss = indices + return [ + [[datapoints[idx] for idx in idxs] for datapoints in datapointss] for idxs in idxss + ] diff --git a/chemprop/exceptions.py b/chemprop/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..61ecab7dfde190832aca351399fbf3ceeda5054d --- /dev/null +++ b/chemprop/exceptions.py @@ -0,0 +1,12 @@ +from typing import Iterable + +from chemprop.utils import pretty_shape + + +class InvalidShapeError(ValueError): + def __init__(self, var_name: str, received: Iterable[int], expected: Iterable[int]): + message = ( + f"arg '{var_name}' has incorrect shape! " + f"got: `{pretty_shape(received)}`. expected: `{pretty_shape(expected)}`" + ) + super().__init__(message) diff --git a/chemprop/featurizers/__init__.py b/chemprop/featurizers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6dc33b475cf70ed18908ab7299338c6ce5b890f5 --- /dev/null +++ b/chemprop/featurizers/__init__.py @@ -0,0 +1,46 @@ +from .base import Featurizer, S, T, VectorFeaturizer, GraphFeaturizer +from .atom import MultiHotAtomFeaturizer, AtomFeatureMode, get_multi_hot_atom_featurizer +from .bond import MultiHotBondFeaturizer +from .molgraph import ( + MolGraphCacheFacade, + MolGraphCache, + MolGraphCacheOnTheFly, + SimpleMoleculeMolGraphFeaturizer, + CondensedGraphOfReactionFeaturizer, + CGRFeaturizer, + RxnMode, +) +from .molecule import ( + MorganFeaturizerMixin, + BinaryFeaturizerMixin, + CountFeaturizerMixin, + MorganBinaryFeaturizer, + MorganCountFeaturizer, + MoleculeFeaturizerRegistry, +) + +__all__ = [ + "Featurizer", + "S", + "T", + "VectorFeaturizer", + "GraphFeaturizer", + "MultiHotAtomFeaturizer", + "AtomFeatureMode", + "get_multi_hot_atom_featurizer", + "MultiHotBondFeaturizer", + "MolGraphCacheFacade", + "MolGraphCache", + "MolGraphCacheOnTheFly", + "SimpleMoleculeMolGraphFeaturizer", + "CondensedGraphOfReactionFeaturizer", + "CGRFeaturizer", + "RxnMode", + "MoleculeFeaturizer", + "MorganFeaturizerMixin", + "BinaryFeaturizerMixin", + "CountFeaturizerMixin", + "MorganBinaryFeaturizer", + "MorganCountFeaturizer", + "MoleculeFeaturizerRegistry", +] diff --git a/chemprop/featurizers/atom.py b/chemprop/featurizers/atom.py new file mode 100644 index 0000000000000000000000000000000000000000..2098c1442f909175cc224ad630e2d3235c8fa6ca --- /dev/null +++ b/chemprop/featurizers/atom.py @@ -0,0 +1,222 @@ +from typing import Sequence +from enum import auto + +import numpy as np +from rdkit.Chem.rdchem import Atom, HybridizationType + +from chemprop.utils.utils import EnumMapping +from chemprop.featurizers.base import VectorFeaturizer + + +class MultiHotAtomFeaturizer(VectorFeaturizer[Atom]): + """A :class:`MultiHotAtomFeaturizer` uses a multi-hot encoding to featurize atoms. + + .. seealso:: + The class provides three default parameterization schemes: + + * :meth:`MultiHotAtomFeaturizer.v1` + * :meth:`MultiHotAtomFeaturizer.v2` + * :meth:`MultiHotAtomFeaturizer.organic` + + The generated atom features are ordered as follows: + * atomic number + * degree + * formal charge + * chiral tag + * number of hydrogens + * hybridization + * aromaticity + * mass + + .. important:: + Each feature, except for aromaticity and mass, includes a pad for unknown values. + + Parameters + ---------- + atomic_nums : Sequence[int] + the choices for atom type denoted by atomic number. Ex: ``[4, 5, 6]`` for C, N and O. + degrees : Sequence[int] + the choices for number of bonds an atom is engaged in. + formal_charges : Sequence[int] + the choices for integer electronic charge assigned to an atom. + chiral_tags : Sequence[int] + the choices for an atom's chiral tag. See :class:`rdkit.Chem.rdchem.ChiralType` for possible integer values. + num_Hs : Sequence[int] + the choices for number of bonded hydrogen atoms. + hybridizations : Sequence[int] + the choices for an atom’s hybridization type. See :class:`rdkit.Chem.rdchem.HybridizationType` for possible integer values. + """ + + def __init__( + self, + atomic_nums: Sequence[int], + degrees: Sequence[int], + formal_charges: Sequence[int], + chiral_tags: Sequence[int], + num_Hs: Sequence[int], + hybridizations: Sequence[int], + ): + self.atomic_nums = {j: i for i, j in enumerate(atomic_nums)} + self.degrees = {i: i for i in degrees} + self.formal_charges = {j: i for i, j in enumerate(formal_charges)} + self.chiral_tags = {i: i for i in chiral_tags} + self.num_Hs = {i: i for i in num_Hs} + self.hybridizations = {ht: i for i, ht in enumerate(hybridizations)} + + self._subfeats: list[dict] = [ + self.atomic_nums, + self.degrees, + self.formal_charges, + self.chiral_tags, + self.num_Hs, + self.hybridizations, + ] + subfeat_sizes = [ + 1 + len(self.atomic_nums), + 1 + len(self.degrees), + 1 + len(self.formal_charges), + 1 + len(self.chiral_tags), + 1 + len(self.num_Hs), + 1 + len(self.hybridizations), + 1, + 1, + ] + self.__size = sum(subfeat_sizes) + + def __len__(self) -> int: + return self.__size + + def __call__(self, a: Atom | None) -> np.ndarray: + x = np.zeros(self.__size) + + if a is None: + return x + + feats = [ + a.GetAtomicNum(), + a.GetTotalDegree(), + a.GetFormalCharge(), + int(a.GetChiralTag()), + int(a.GetTotalNumHs()), + a.GetHybridization(), + ] + i = 0 + for feat, choices in zip(feats, self._subfeats): + j = choices.get(feat, len(choices)) + x[i + j] = 1 + i += len(choices) + 1 + x[i] = int(a.GetIsAromatic()) + x[i + 1] = 0.01 * a.GetMass() + + return x + + def num_only(self, a: Atom) -> np.ndarray: + """featurize the atom by setting only the atomic number bit""" + x = np.zeros(len(self)) + + if a is None: + return x + + i = self.atomic_nums.get(a.GetAtomicNum(), len(self.atomic_nums)) + x[i] = 1 + + return x + + @classmethod + def v1(cls, max_atomic_num: int = 100): + """The original implementation used in Chemprop V1 [1]_, [2]_. + + Parameters + ---------- + max_atomic_num : int, default=100 + Include a bit for all atomic numbers in the interval :math:`[1, \mathtt{max_atomic_num}]` + + References + ----------- + .. [1] Yang, K.; Swanson, K.; Jin, W.; Coley, C.; Eiden, P.; Gao, H.; Guzman-Perez, A.; Hopper, T.; + Kelley, B.; Mathea, M.; Palmer, A. "Analyzing Learned Molecular Representations for Property Prediction." + J. Chem. Inf. Model. 2019, 59 (8), 3370–3388. https://doi.org/10.1021/acs.jcim.9b00237 + .. [2] Heid, E.; Greenman, K.P.; Chung, Y.; Li, S.C.; Graff, D.E.; Vermeire, F.H.; Wu, H.; Green, W.H.; McGill, + C.J. "Chemprop: A machine learning package for chemical property prediction." J. Chem. Inf. Model. 2024, + 64 (1), 9–17. https://doi.org/10.1021/acs.jcim.3c01250 + """ + + return cls( + atomic_nums=list(range(1, max_atomic_num + 1)), + degrees=list(range(6)), + formal_charges=[-1, -2, 1, 2, 0], + chiral_tags=list(range(4)), + num_Hs=list(range(5)), + hybridizations=[ + HybridizationType.SP, + HybridizationType.SP2, + HybridizationType.SP3, + HybridizationType.SP3D, + HybridizationType.SP3D2, + ], + ) + + @classmethod + def v2(cls): + """An implementation that includes an atom type bit for all elements in the first four rows of the periodic table plus iodine.""" + + return cls( + atomic_nums=list(range(1, 37)) + [53], + degrees=list(range(6)), + formal_charges=[-1, -2, 1, 2, 0], + chiral_tags=list(range(4)), + num_Hs=list(range(5)), + hybridizations=[ + HybridizationType.S, + HybridizationType.SP, + HybridizationType.SP2, + HybridizationType.SP2D, + HybridizationType.SP3, + HybridizationType.SP3D, + HybridizationType.SP3D2, + ], + ) + + @classmethod + def organic(cls): + r"""A specific parameterization intended for use with organic or drug-like molecules. + + This parameterization features: + 1. includes an atomic number bit only for H, B, C, N, O, F, Si, P, S, Cl, Br, and I atoms + 2. a hybridization bit for :math:`s, sp, sp^2` and :math:`sp^3` hybridizations. + """ + + return cls( + atomic_nums=[1, 5, 6, 7, 8, 9, 14, 15, 16, 17, 35, 53], + degrees=list(range(6)), + formal_charges=[-1, -2, 1, 2, 0], + chiral_tags=list(range(4)), + num_Hs=list(range(5)), + hybridizations=[ + HybridizationType.S, + HybridizationType.SP, + HybridizationType.SP2, + HybridizationType.SP3, + ], + ) + + +class AtomFeatureMode(EnumMapping): + """The mode of an atom is used for featurization into a `MolGraph`""" + + V1 = auto() + V2 = auto() + ORGANIC = auto() + + +def get_multi_hot_atom_featurizer(mode: str | AtomFeatureMode) -> MultiHotAtomFeaturizer: + """Build the corresponding multi-hot atom featurizer.""" + match AtomFeatureMode.get(mode): + case AtomFeatureMode.V1: + return MultiHotAtomFeaturizer.v1() + case AtomFeatureMode.V2: + return MultiHotAtomFeaturizer.v2() + case AtomFeatureMode.ORGANIC: + return MultiHotAtomFeaturizer.organic() + case _: + raise RuntimeError("unreachable code reached!") diff --git a/chemprop/featurizers/base.py b/chemprop/featurizers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..fd0ce8a457b3d8a29d57a029ef79ca16ed0d586f --- /dev/null +++ b/chemprop/featurizers/base.py @@ -0,0 +1,30 @@ +from abc import abstractmethod +from collections.abc import Sized +from typing import Generic, TypeVar + +import numpy as np + +from chemprop.data.molgraph import MolGraph + +S = TypeVar("S") +T = TypeVar("T") + + +class Featurizer(Generic[S, T]): + """An :class:`Featurizer` featurizes inputs type ``S`` into outputs of + type ``T``.""" + + @abstractmethod + def __call__(self, input: S, *args, **kwargs) -> T: + """featurize an input""" + + +class VectorFeaturizer(Featurizer[S, np.ndarray], Sized): + ... + + +class GraphFeaturizer(Featurizer[S, MolGraph]): + @property + @abstractmethod + def shape(self) -> tuple[int, int]: + ... diff --git a/chemprop/featurizers/bond.py b/chemprop/featurizers/bond.py new file mode 100644 index 0000000000000000000000000000000000000000..aaed42f0aae12f588e7e6ae1cef50aa90dcfe234 --- /dev/null +++ b/chemprop/featurizers/bond.py @@ -0,0 +1,92 @@ +from typing import Sequence + +import numpy as np +from rdkit.Chem.rdchem import Bond, BondType + +from chemprop.featurizers.base import VectorFeaturizer + + +class MultiHotBondFeaturizer(VectorFeaturizer[Bond]): + """A :class:`MultiHotBondFeaturizer` feauturizes bonds based on the following attributes: + + * ``null``-ity (i.e., is the bond ``None``?) + * bond type + * conjugated? + * in ring? + * stereochemistry + + The feature vectors produced by this featurizer have the following (general) signature: + + +---------------------+-----------------+--------------+ + | slice [start, stop) | subfeature | unknown pad? | + +=====================+=================+==============+ + | 0-1 | null? | N | + +---------------------+-----------------+--------------+ + | 1-5 | bond type | N | + +---------------------+-----------------+--------------+ + | 5-6 | conjugated? | N | + +---------------------+-----------------+--------------+ + | 6-8 | in ring? | N | + +---------------------+-----------------+--------------+ + | 7-14 | stereochemistry | Y | + +---------------------+-----------------+--------------+ + + **NOTE**: the above signature only applies for the default arguments, as the bond type and + sterochemistry slices can increase in size depending on the input arguments. + + Parameters + ---------- + bond_types : Sequence[BondType] | None, default=[SINGLE, DOUBLE, TRIPLE, AROMATIC] + the known bond types + stereos : Sequence[int] | None, default=[0, 1, 2, 3, 4, 5] + the known bond stereochemistries. See [1]_ for more details + + References + ---------- + .. [1] https://www.rdkit.org/docs/source/rdkit.Chem.rdchem.html#rdkit.Chem.rdchem.BondStereo.values + """ + + def __init__( + self, bond_types: Sequence[BondType] | None = None, stereos: Sequence[int] | None = None + ): + self.bond_types = bond_types or [ + BondType.SINGLE, + BondType.DOUBLE, + BondType.TRIPLE, + BondType.AROMATIC, + ] + self.stereo = stereos or range(6) + + def __len__(self): + return 1 + len(self.bond_types) + 2 + (len(self.stereo) + 1) + + def __call__(self, b: Bond) -> np.ndarray: + x = np.zeros(len(self), int) + + if b is None: + x[0] = 1 + return x + + i = 1 + bond_type = b.GetBondType() + bt_bit, size = self.one_hot_index(bond_type, self.bond_types) + if bt_bit != size: + x[i + bt_bit] = 1 + i += size - 1 + + x[i] = int(b.GetIsConjugated()) + x[i + 1] = int(b.IsInRing()) + i += 2 + + stereo_bit, _ = self.one_hot_index(int(b.GetStereo()), self.stereo) + x[i + stereo_bit] = 1 + + return x + + @classmethod + def one_hot_index(cls, x, xs: Sequence) -> tuple[int, int]: + """Returns a tuple of the index of ``x`` in ``xs`` and ``len(xs) + 1`` if ``x`` is in ``xs``. + Otherwise, returns a tuple with ``len(xs)`` and ``len(xs) + 1``.""" + n = len(xs) + + return xs.index(x) if x in xs else n, n + 1 diff --git a/chemprop/featurizers/molecule.py b/chemprop/featurizers/molecule.py new file mode 100644 index 0000000000000000000000000000000000000000..ad701feb23cba33d54b78404fb93721135b785f0 --- /dev/null +++ b/chemprop/featurizers/molecule.py @@ -0,0 +1,43 @@ +import numpy as np +from rdkit import Chem +from rdkit.Chem import Mol +from rdkit.Chem.rdFingerprintGenerator import GetMorganGenerator + +from chemprop.featurizers.base import VectorFeaturizer +from chemprop.utils import ClassRegistry + +MoleculeFeaturizerRegistry = ClassRegistry[VectorFeaturizer[Mol]]() + + +class MorganFeaturizerMixin: + def __init__(self, radius: int = 2, length: int = 2048, include_chirality: bool = True): + if radius < 0: + raise ValueError(f"arg 'radius' must be >= 0! got: {radius}") + + self.length = length + self.F = GetMorganGenerator( + radius=radius, fpSize=length, includeChirality=include_chirality + ) + + def __len__(self) -> int: + return self.length + + +class BinaryFeaturizerMixin: + def __call__(self, mol: Chem.Mol) -> np.ndarray: + return self.F.GetFingerprintAsNumPy(mol) + + +class CountFeaturizerMixin: + def __call__(self, mol: Chem.Mol) -> np.ndarray: + return self.F.GetCountFingerprintAsNumPy(mol).astype(np.int32) + + +@MoleculeFeaturizerRegistry("morgan_binary") +class MorganBinaryFeaturizer(MorganFeaturizerMixin, BinaryFeaturizerMixin, VectorFeaturizer[Mol]): + pass + + +@MoleculeFeaturizerRegistry("morgan_count") +class MorganCountFeaturizer(MorganFeaturizerMixin, CountFeaturizerMixin, VectorFeaturizer[Mol]): + pass diff --git a/chemprop/featurizers/molgraph/__init__.py b/chemprop/featurizers/molgraph/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b5c44897f56e895511b6592652cf867006adea56 --- /dev/null +++ b/chemprop/featurizers/molgraph/__init__.py @@ -0,0 +1,13 @@ +from .cache import MolGraphCacheFacade, MolGraphCache, MolGraphCacheOnTheFly +from .molecule import SimpleMoleculeMolGraphFeaturizer +from .reaction import CondensedGraphOfReactionFeaturizer, CGRFeaturizer, RxnMode + +__all__ = [ + "MolGraphCacheFacade", + "MolGraphCache", + "MolGraphCacheOnTheFly", + "SimpleMoleculeMolGraphFeaturizer", + "CondensedGraphOfReactionFeaturizer", + "CGRFeaturizer", + "RxnMode", +] diff --git a/chemprop/featurizers/molgraph/cache.py b/chemprop/featurizers/molgraph/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..8f2ca32a4ad620dd12425ee6279c710af9f03d3b --- /dev/null +++ b/chemprop/featurizers/molgraph/cache.py @@ -0,0 +1,89 @@ +from abc import abstractmethod +from collections.abc import Sequence +from typing import Generic, Iterable + +import numpy as np + +from chemprop.featurizers.base import S, Featurizer +from chemprop.data.molgraph import MolGraph + + +class MolGraphCacheFacade(Sequence[MolGraph], Generic[S]): + """ + A :class:`MolGraphCacheFacade` provided an interface for caching + :class:`~chemprop.data.molgraph.MolGraph`\s. + + .. note:: + This class only provides a facade for a cached dataset, but it _does not guarantee_ + whether the underlying data is truly cached. + + + Parameters + ---------- + inputs : Iterable[S] + The inputs to be featurized. + V_fs : Iterable[np.ndarray] + The node features for each input. + E_fs : Iterable[np.ndarray] + The edge features for each input. + featurizer : Featurizer[S, MolGraph] + The featurizer with which to generate the + :class:`~chemprop.data.molgraph.MolGraph`\s. + """ + + @abstractmethod + def __init__( + self, + inputs: Iterable[S], + V_fs: Iterable[np.ndarray], + E_fs: Iterable[np.ndarray], + featurizer: Featurizer[S, MolGraph], + ): + pass + + +class MolGraphCache(MolGraphCacheFacade): + """ + A :class:`MolGraphCache` precomputes the corresponding + :class:`~chemprop.data.molgraph.MolGraph`\s and caches them in memory. + """ + + def __init__( + self, + inputs: Iterable[S], + V_fs: Iterable[np.ndarray | None], + E_fs: Iterable[np.ndarray | None], + featurizer: Featurizer[S, MolGraph], + ): + self._mgs = [featurizer(input, V_f, E_f) for input, V_f, E_f in zip(inputs, V_fs, E_fs)] + + def __len__(self) -> int: + return len(self._mgs) + + def __getitem__(self, index: int) -> MolGraph: + return self._mgs[index] + + +class MolGraphCacheOnTheFly(MolGraphCacheFacade): + """ + A :class:`MolGraphCacheOnTheFly` computes the corresponding + :class:`~chemprop.data.molgraph.MolGraph`\s as they are requested. + """ + + def __init__( + self, + inputs: Iterable[S], + V_fs: Iterable[np.ndarray | None], + E_fs: Iterable[np.ndarray | None], + featurizer: Featurizer[S, MolGraph], + ): + self._inputs = list(inputs) + self._V_fs = list(V_fs) + self._E_fs = list(E_fs) + self._featurizer = featurizer + + def __len__(self) -> int: + return len(self._inputs) + + def __getitem__(self, index: int) -> MolGraph: + return self._featurizer(self._inputs[index], self._V_fs[index], self._E_fs[index]) diff --git a/chemprop/featurizers/molgraph/mixins.py b/chemprop/featurizers/molgraph/mixins.py new file mode 100644 index 0000000000000000000000000000000000000000..7229df2dc95bfb57c3df9b4ac27425b0431e86dc --- /dev/null +++ b/chemprop/featurizers/molgraph/mixins.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from rdkit.Chem.rdchem import Atom, Bond + +from chemprop.featurizers.base import VectorFeaturizer +from chemprop.featurizers.atom import MultiHotAtomFeaturizer +from chemprop.featurizers.bond import MultiHotBondFeaturizer + + +@dataclass +class _MolGraphFeaturizerMixin: + atom_featurizer: VectorFeaturizer[Atom] = field(default_factory=MultiHotAtomFeaturizer.v2) + bond_featurizer: VectorFeaturizer[Bond] = field(default_factory=MultiHotBondFeaturizer) + + def __post_init__(self): + self.atom_fdim = len(self.atom_featurizer) + self.bond_fdim = len(self.bond_featurizer) + + @property + def shape(self) -> tuple[int, int]: + """the feature dimension of the atoms and bonds, respectively, of `MolGraph`s generated by + this featurizer""" + return self.atom_fdim, self.bond_fdim diff --git a/chemprop/featurizers/molgraph/molecule.py b/chemprop/featurizers/molgraph/molecule.py new file mode 100644 index 0000000000000000000000000000000000000000..75943b5902f9ad742b4b4a39e9d15e1d4a76179d --- /dev/null +++ b/chemprop/featurizers/molgraph/molecule.py @@ -0,0 +1,95 @@ +from dataclasses import InitVar, dataclass + +import numpy as np +from rdkit import Chem +from rdkit.Chem import Mol + +from chemprop.data.molgraph import MolGraph +from chemprop.featurizers.base import GraphFeaturizer +from chemprop.featurizers.molgraph.mixins import _MolGraphFeaturizerMixin + + +@dataclass +class SimpleMoleculeMolGraphFeaturizer(_MolGraphFeaturizerMixin, GraphFeaturizer[Mol]): + """A :class:`SimpleMoleculeMolGraphFeaturizer` is the default implementation of a + :class:`MoleculeMolGraphFeaturizer` + + Parameters + ---------- + atom_featurizer : AtomFeaturizer, default=MultiHotAtomFeaturizer() + the featurizer with which to calculate feature representations of the atoms in a given + molecule + bond_featurizer : BondFeaturizer, default=MultiHotBondFeaturizer() + the featurizer with which to calculate feature representations of the bonds in a given + molecule + extra_atom_fdim : int, default=0 + the dimension of the additional features that will be concatenated onto the calculated + features of each atom + extra_bond_fdim : int, default=0 + the dimension of the additional features that will be concatenated onto the calculated + features of each bond + """ + + extra_atom_fdim: InitVar[int] = 0 + extra_bond_fdim: InitVar[int] = 0 + + def __post_init__(self, extra_atom_fdim: int = 0, extra_bond_fdim: int = 0): + super().__post_init__() + + self.extra_atom_fdim = extra_atom_fdim + self.extra_bond_fdim = extra_bond_fdim + self.atom_fdim += self.extra_atom_fdim + self.bond_fdim += self.extra_bond_fdim + + def __call__( + self, + mol: Chem.Mol, + atom_features_extra: np.ndarray | None = None, + bond_features_extra: np.ndarray | None = None, + ) -> MolGraph: + n_atoms = mol.GetNumAtoms() + n_bonds = mol.GetNumBonds() + + if atom_features_extra is not None and len(atom_features_extra) != n_atoms: + raise ValueError( + "Input molecule must have same number of atoms as `len(atom_features_extra)`!" + f"got: {n_atoms} and {len(atom_features_extra)}, respectively" + ) + if bond_features_extra is not None and len(bond_features_extra) != n_bonds: + raise ValueError( + "Input molecule must have same number of bonds as `len(bond_features_extra)`!" + f"got: {n_bonds} and {len(bond_features_extra)}, respectively" + ) + + if n_atoms == 0: + V = np.zeros((1, self.atom_fdim), dtype=np.single) + else: + V = np.array([self.atom_featurizer(a) for a in mol.GetAtoms()], dtype=np.single) + E = np.empty((2 * n_bonds, self.bond_fdim)) + edge_index = [[], []] + + if atom_features_extra is not None: + V = np.hstack((V, atom_features_extra)) + + i = 0 + for u in range(n_atoms): + for v in range(u + 1, n_atoms): + bond = mol.GetBondBetweenAtoms(u, v) + if bond is None: + continue + + x_e = self.bond_featurizer(bond) + if bond_features_extra is not None: + x_e = np.concatenate((x_e, bond_features_extra[bond.GetIdx()]), dtype=np.single) + + E[i : i + 2] = x_e + + edge_index[0].extend([u, v]) + edge_index[1].extend([v, u]) + + i += 2 + + rev_edge_index = np.arange(len(E)).reshape(-1, 2)[:, ::-1].ravel() + edge_index = np.array(edge_index, int) + + return MolGraph(V, E, edge_index, rev_edge_index) diff --git a/chemprop/featurizers/molgraph/reaction.py b/chemprop/featurizers/molgraph/reaction.py new file mode 100644 index 0000000000000000000000000000000000000000..0a2c890ab8998489f4f5672dd24d8d0f229a492e --- /dev/null +++ b/chemprop/featurizers/molgraph/reaction.py @@ -0,0 +1,333 @@ +from dataclasses import InitVar, dataclass +from enum import auto +from typing import Iterable, Sequence, TypeAlias +import warnings + +import numpy as np +from rdkit import Chem +from rdkit.Chem.rdchem import Bond, Mol +from chemprop.featurizers.base import GraphFeaturizer + +from chemprop.types import Rxn +from chemprop.data.molgraph import MolGraph +from chemprop.featurizers.molgraph.mixins import _MolGraphFeaturizerMixin +from chemprop.utils.utils import EnumMapping + + +class RxnMode(EnumMapping): + """The mode by which a reaction should be featurized into a `MolGraph`""" + + REAC_PROD = auto() + """concatenate the reactant features with the product features.""" + REAC_PROD_BALANCE = auto() + """concatenate the reactant features with the products feature and balances imbalanced + reactions""" + REAC_DIFF = auto() + """concatenates the reactant features with the difference in features between reactants and + products""" + REAC_DIFF_BALANCE = auto() + """concatenates the reactant features with the difference in features between reactants and + product and balances imbalanced reactions""" + PROD_DIFF = auto() + """concatenates the product features with the difference in features between reactants and + products""" + PROD_DIFF_BALANCE = auto() + """concatenates the product features with the difference in features between reactants and + products and balances imbalanced reactions""" + + +@dataclass +class CondensedGraphOfReactionFeaturizer(_MolGraphFeaturizerMixin, GraphFeaturizer[Rxn]): + """A :class:`CondensedGraphOfReactionFeaturizer` featurizes reactions using the condensed + reaction graph method utilized in [1]_ + + **NOTE**: This class *does not* accept a :class:`AtomFeaturizer` instance. This is because + it requries the :meth:`num_only()` method, which is only implemented in the concrete + :class:`AtomFeaturizer` class + + Parameters + ---------- + atom_featurizer : AtomFeaturizer, default=AtomFeaturizer() + the featurizer with which to calculate feature representations of the atoms in a given + molecule + bond_featurizer : BondFeaturizerBase, default=BondFeaturizer() + the featurizer with which to calculate feature representations of the bonds in a given + molecule + mode_ : Union[str, ReactionMode], default=ReactionMode.REAC_DIFF + the mode by which to featurize the reaction as either the string code or enum value + + References + ---------- + .. [1] Heid, E.; Green, W.H. "Machine Learning of Reaction Properties via Learned + Representations of the Condensed Graph of Reaction." J. Chem. Inf. Model. 2022, 62, + 2101-2110. https://doi.org/10.1021/acs.jcim.1c00975 + """ + + mode_: InitVar[str | RxnMode] = RxnMode.REAC_DIFF + + def __post_init__(self, mode_: str | RxnMode): + super().__post_init__() + + self.mode = mode_ + self.atom_fdim += len(self.atom_featurizer) - len(self.atom_featurizer.atomic_nums) - 1 + self.bond_fdim *= 2 + + @property + def mode(self) -> RxnMode: + return self.__mode + + @mode.setter + def mode(self, m: str | RxnMode): + self.__mode = RxnMode.get(m) + + def __call__( + self, + rxn: tuple[Chem.Mol, Chem.Mol], + atom_features_extra: np.ndarray | None = None, + bond_features_extra: np.ndarray | None = None, + ) -> MolGraph: + """Featurize the input reaction into a molecular graph + + Parameters + ---------- + rxn : Rxn + a 2-tuple of atom-mapped rdkit molecules, where the 0th element is the reactant and the + 1st element is the product + atom_features_extra : np.ndarray | None, default=None + *UNSUPPORTED* maintained only to maintain parity with the method signature of the + `MoleculeFeaturizer` + bond_features_extra : np.ndarray | None, default=None + *UNSUPPORTED* maintained only to maintain parity with the method signature of the + `MoleculeFeaturizer` + + Returns + ------- + MolGraph + the molecular graph of the reaction + """ + + if atom_features_extra is not None: + warnings.warn("'atom_features_extra' is currently unsupported for reactions") + if bond_features_extra is not None: + warnings.warn("'bond_features_extra' is currently unsupported for reactions") + + reac, pdt = rxn + r2p_idx_map, pdt_idxs, reac_idxs = self.map_reac_to_prod(reac, pdt) + + V = self._calc_node_feature_matrix(reac, pdt, r2p_idx_map, pdt_idxs, reac_idxs) + E = [] + edge_index = [[], []] + + n_atoms_tot = len(V) + n_atoms_reac = reac.GetNumAtoms() + + i = 0 + for u in range(n_atoms_tot): + for v in range(u + 1, n_atoms_tot): + b_reac, b_prod = self._get_bonds( + reac, pdt, r2p_idx_map, pdt_idxs, n_atoms_reac, u, v + ) + if b_reac is None and b_prod is None: + continue + + x_e = self._calc_edge_feature(b_reac, b_prod) + E.extend([x_e, x_e]) + edge_index[0].extend([u, v]) + edge_index[1].extend([v, u]) + + i += 2 + + E = np.array(E) + rev_edge_index = np.arange(len(E)).reshape(-1, 2)[:, ::-1].ravel() + edge_index = np.array(edge_index, int) + + return MolGraph(V, E, edge_index, rev_edge_index) + + def _calc_node_feature_matrix( + self, + rct: Mol, + pdt: Mol, + r2p_idx_map: dict[int, int], + pdt_idxs: Iterable[int], + reac_idxs: Iterable[int], + ) -> np.ndarray: + """Calculate the node feature matrix for the reaction""" + X_v_r1 = np.array([self.atom_featurizer(a) for a in rct.GetAtoms()]) + X_v_p2 = np.array([self.atom_featurizer(pdt.GetAtomWithIdx(i)) for i in pdt_idxs]) + X_v_p2 = X_v_p2.reshape(-1, X_v_r1.shape[1]) + + if self.mode in [RxnMode.REAC_DIFF, RxnMode.PROD_DIFF, RxnMode.REAC_PROD]: + # Reactant: + # (1) regular features for each atom in the reactants + # (2) zero features for each atom that's only in the products + X_v_r2 = [self.atom_featurizer.num_only(pdt.GetAtomWithIdx(i)) for i in pdt_idxs] + X_v_r2 = np.array(X_v_r2).reshape(-1, X_v_r1.shape[1]) + + # Product: + # (1) either (a) product-side features for each atom in both + # or (b) zero features for each atom only in the reatants + # (2) regular features for each atom only in the products + X_v_p1 = np.array( + [ + ( + self.atom_featurizer(pdt.GetAtomWithIdx(r2p_idx_map[a.GetIdx()])) + if a.GetIdx() not in reac_idxs + else self.atom_featurizer.num_only(a) + ) + for a in rct.GetAtoms() + ] + ) + else: + # Reactant: + # (1) regular features for each atom in the reactants + # (2) regular features for each atom only in the products + X_v_r2 = [self.atom_featurizer(pdt.GetAtomWithIdx(i)) for i in pdt_idxs] + X_v_r2 = np.array(X_v_r2).reshape(-1, X_v_r1.shape[1]) + + # Product: + # (1) either (a) product-side features for each atom in both + # or (b) reactant-side features for each atom only in the reatants + # (2) regular features for each atom only in the products + X_v_p1 = np.array( + [ + ( + self.atom_featurizer(pdt.GetAtomWithIdx(r2p_idx_map[a.GetIdx()])) + if a.GetIdx() not in reac_idxs + else self.atom_featurizer(a) + ) + for a in rct.GetAtoms() + ] + ) + + X_v_r = np.concatenate((X_v_r1, X_v_r2)) + X_v_p = np.concatenate((X_v_p1, X_v_p2)) + + m = min(len(X_v_r), len(X_v_p)) + + if self.mode in [RxnMode.REAC_PROD, RxnMode.REAC_PROD_BALANCE]: + X_v = np.hstack((X_v_r[:m], X_v_p[:m, len(self.atom_featurizer.atomic_nums) + 1 :])) + else: + X_v_d = X_v_p[:m] - X_v_r[:m] + if self.mode in [RxnMode.REAC_DIFF, RxnMode.REAC_DIFF_BALANCE]: + X_v = np.hstack((X_v_r[:m], X_v_d[:m, len(self.atom_featurizer.atomic_nums) + 1 :])) + else: + X_v = np.hstack((X_v_p[:m], X_v_d[:m, len(self.atom_featurizer.atomic_nums) + 1 :])) + + return X_v + + def _get_bonds( + self, + rct: Bond, + pdt: Bond, + ri2pj: dict[int, int], + pids: Sequence[int], + n_atoms_r: int, + u: int, + v: int, + ) -> tuple[Bond, Bond]: + """get the corresponding reactant- and product-side bond, respectively, betweeen atoms `u` and `v`""" + if u >= n_atoms_r and v >= n_atoms_r: + b_prod = pdt.GetBondBetweenAtoms(pids[u - n_atoms_r], pids[v - n_atoms_r]) + + if self.mode in [ + RxnMode.REAC_PROD_BALANCE, + RxnMode.REAC_DIFF_BALANCE, + RxnMode.PROD_DIFF_BALANCE, + ]: + b_reac = b_prod + else: + b_reac = None + elif u < n_atoms_r and v >= n_atoms_r: # One atom only in product + b_reac = None + + if u in ri2pj: + b_prod = pdt.GetBondBetweenAtoms(ri2pj[u], pids[v - n_atoms_r]) + else: # Atom atom only in reactant, the other only in product + b_prod = None + else: + b_reac = rct.GetBondBetweenAtoms(u, v) + + if u in ri2pj and v in ri2pj: # Both atoms in both reactant and product + b_prod = pdt.GetBondBetweenAtoms(ri2pj[u], ri2pj[v]) + elif self.mode in [ + RxnMode.REAC_PROD_BALANCE, + RxnMode.REAC_DIFF_BALANCE, + RxnMode.PROD_DIFF_BALANCE, + ]: + b_prod = None if (u in ri2pj or v in ri2pj) else b_reac + else: # One or both atoms only in reactant + b_prod = None + + return b_reac, b_prod + + def _calc_edge_feature(self, b_reac: Bond, b_pdt: Bond): + """Calculate the global features of the two bonds""" + x_e_r = self.bond_featurizer(b_reac) + x_e_p = self.bond_featurizer(b_pdt) + x_e_d = x_e_p - x_e_r + + if self.mode in [RxnMode.REAC_PROD, RxnMode.REAC_PROD_BALANCE]: + x_e = np.hstack((x_e_r, x_e_p)) + elif self.mode in [RxnMode.REAC_DIFF, RxnMode.REAC_DIFF_BALANCE]: + x_e = np.hstack((x_e_r, x_e_d)) + else: + x_e = np.hstack((x_e_p, x_e_d)) + + return x_e + + @classmethod + def map_reac_to_prod( + cls, reacs: Chem.Mol, pdts: Chem.Mol + ) -> tuple[dict[int, int], list[int], list[int]]: + """Map atom indices between corresponding atoms in the reactant and product molecules + + Parameters + ---------- + reacs : Chem.Mol + An RDKit molecule of the reactants + pdts : Chem.Mol + An RDKit molecule of the products + + Returns + ------- + ri2pi : dict[int, int] + A dictionary of corresponding atom indices from reactant atoms to product atoms + pdt_idxs : list[int] + atom indices of poduct atoms + rct_idxs : list[int] + atom indices of reactant atoms + """ + pdt_idxs = [] + mapno2pj = {} + reac_atommap_nums = {a.GetAtomMapNum() for a in reacs.GetAtoms()} + + for a in pdts.GetAtoms(): + map_num = a.GetAtomMapNum() + j = a.GetIdx() + + if map_num > 0: + mapno2pj[map_num] = j + if map_num not in reac_atommap_nums: + pdt_idxs.append(j) + else: + pdt_idxs.append(j) + + rct_idxs = [] + r2p_idx_map = {} + + for a in reacs.GetAtoms(): + map_num = a.GetAtomMapNum() + i = a.GetIdx() + + if map_num > 0: + try: + r2p_idx_map[i] = mapno2pj[map_num] + except KeyError: + rct_idxs.append(i) + else: + rct_idxs.append(i) + + return r2p_idx_map, pdt_idxs, rct_idxs + + +CGRFeaturizer: TypeAlias = CondensedGraphOfReactionFeaturizer diff --git a/chemprop/models/__init__.py b/chemprop/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4cc506a0bfed6bda61b98be2a162d91ef05495bf --- /dev/null +++ b/chemprop/models/__init__.py @@ -0,0 +1,5 @@ +from .model import MPNN +from .multi import MulticomponentMPNN +from .utils import load_model, save_model + +__all__ = ["MPNN", "MulticomponentMPNN", "load_model", "save_model"] diff --git a/chemprop/models/model.py b/chemprop/models/model.py new file mode 100644 index 0000000000000000000000000000000000000000..e72be3acaf09632f91b58918d7350987540fe6f5 --- /dev/null +++ b/chemprop/models/model.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from typing import Iterable + +from lightning import pytorch as pl +import torch +from torch import nn, Tensor, optim + +from chemprop.data import TrainingBatch, BatchMolGraph +from chemprop.nn.metrics import Metric +from chemprop.nn import MessagePassing, Aggregation, Predictor, LossFunction +from chemprop.schedulers import NoamLR +from chemprop.nn.transforms import ScaleTransform + + +class MPNN(pl.LightningModule): + r"""An :class:`MPNN` is a sequence of message passing layers, an aggregation routine, and a + predictor routine. + + The first two modules calculate learned fingerprints from an input molecule + reaction graph, and the final module takes these learned fingerprints as input to calculate a + final prediction. I.e., the following operation: + + .. math:: + \mathtt{MPNN}(\mathcal{G}) = + \mathtt{predictor}(\mathtt{agg}(\mathtt{message\_passing}(\mathcal{G}))) + + The full model is trained end-to-end. + + Parameters + ---------- + message_passing : MessagePassing + the message passing block to use to calculate learned fingerprints + agg : Aggregation + the aggregation operation to use during molecule-level predictor + predictor : Predictor + the function to use to calculate the final prediction + batch_norm : bool, default=True + if `True`, apply batch normalization to the output of the aggregation operation + metrics : Iterable[Metric] | None, default=None + the metrics to use to evaluate the model during training and evaluation + warmup_epochs : int, default=2 + the number of epochs to use for the learning rate warmup + init_lr : int, default=1e-4 + the initial learning rate + max_lr : float, default=1e-3 + the maximum learning rate + final_lr : float, default=1e-4 + the final learning rate + + Raises + ------ + ValueError + if the output dimension of the message passing block does not match the input dimension of + the predictor function + """ + + def __init__( + self, + message_passing: MessagePassing, + agg: Aggregation, + predictor: Predictor, + batch_norm: bool = True, + metrics: Iterable[Metric] | None = None, + warmup_epochs: int = 2, + init_lr: float = 1e-4, + max_lr: float = 1e-3, + final_lr: float = 1e-4, + X_d_transform: ScaleTransform | None = None, + ): + super().__init__() + + self.save_hyperparameters(ignore=["message_passing", "agg", "predictor"]) + self.hparams.update( + { + "message_passing": message_passing.hparams, + "agg": agg.hparams, + "predictor": predictor.hparams, + } + ) + + self.message_passing = message_passing + self.agg = agg + self.bn = nn.BatchNorm1d(self.message_passing.output_dim) if batch_norm else nn.Identity() + self.predictor = predictor + + self.X_d_transform = X_d_transform if X_d_transform is not None else nn.Identity() + + self.metrics = ( + [*metrics, self.criterion] + if metrics + else [self.predictor._T_default_metric(), self.criterion] + ) + + self.warmup_epochs = warmup_epochs + self.init_lr = init_lr + self.max_lr = max_lr + self.final_lr = final_lr + + @property + def output_dim(self) -> int: + return self.predictor.output_dim + + @property + def n_tasks(self) -> int: + return self.predictor.n_tasks + + @property + def n_targets(self) -> int: + return self.predictor.n_targets + + @property + def criterion(self) -> LossFunction: + return self.predictor.criterion + + def fingerprint( + self, bmg: BatchMolGraph, V_d: Tensor | None = None, X_d: Tensor | None = None + ) -> Tensor: + """the learned fingerprints for the input molecules""" + H_v = self.message_passing(bmg, V_d) + H = self.agg(H_v, bmg.batch) + H = self.bn(H) + + return H if X_d is None else torch.cat((H, self.X_d_transform(X_d)), 1) + + def encoding( + self, bmg: BatchMolGraph, V_d: Tensor | None = None, X_d: Tensor | None = None, i: int = -1 + ) -> Tensor: + """Calculate the :attr:`i`-th hidden representation""" + return self.predictor.encode(self.fingerprint(bmg, V_d, X_d), i) + + def forward( + self, bmg: BatchMolGraph, V_d: Tensor | None = None, X_d: Tensor | None = None + ) -> Tensor: + """Generate predictions for the input molecules/reactions""" + return self.predictor(self.fingerprint(bmg, V_d, X_d)) + + def training_step(self, batch: TrainingBatch, batch_idx): + bmg, V_d, X_d, targets, weights, lt_mask, gt_mask = batch + + mask = targets.isfinite() + targets = targets.nan_to_num(nan=0.0) + + Z = self.fingerprint(bmg, V_d, X_d) + preds = self.predictor.train_step(Z) + l = self.criterion(preds, targets, mask, weights, lt_mask, gt_mask) + + self.log("train_loss", l, prog_bar=True) + + return l + + def on_validation_model_eval(self) -> None: + self.eval() + self.predictor.output_transform.train() + + def validation_step(self, batch: TrainingBatch, batch_idx: int = 0): + losses = self._evaluate_batch(batch) + metric2loss = {f"val/{m.alias}": l for m, l in zip(self.metrics, losses)} + + self.log_dict(metric2loss, batch_size=len(batch[0])) + self.log("val_loss", losses[0], batch_size=len(batch[0]), prog_bar=True) + + def test_step(self, batch: TrainingBatch, batch_idx: int = 0): + losses = self._evaluate_batch(batch) + metric2loss = {f"batch_averaged_test/{m.alias}": l for m, l in zip(self.metrics, losses)} + + self.log_dict(metric2loss, batch_size=len(batch[0])) + + def _evaluate_batch(self, batch) -> list[Tensor]: + bmg, V_d, X_d, targets, _, lt_mask, gt_mask = batch + + mask = targets.isfinite() + targets = targets.nan_to_num(nan=0.0) + preds = self(bmg, V_d, X_d) + + return [ + metric(preds, targets, mask, None, lt_mask, gt_mask) for metric in self.metrics[:-1] + ] + + def predict_step(self, batch: TrainingBatch, batch_idx: int, dataloader_idx: int = 0) -> Tensor: + """Return the predictions of the input batch + + Parameters + ---------- + batch : TrainingBatch + the input batch + + Returns + ------- + Tensor + a tensor of varying shape depending on the task type: + + * regression/binary classification: ``n x (t * s)``, where ``n`` is the number of input + molecules/reactions, ``t`` is the number of tasks, and ``s`` is the number of targets + per task. The final dimension is flattened, so that the targets for each task are + grouped. I.e., the first ``t`` elements are the first target for each task, the second + ``t`` elements the second target, etc. + * multiclass classification: ``n x t x c``, where ``c`` is the number of classes + """ + bmg, X_vd, X_d, *_ = batch + + return self(bmg, X_vd, X_d) + + def configure_optimizers(self): + opt = optim.Adam(self.parameters(), self.init_lr) + + lr_sched = NoamLR( + opt, + self.warmup_epochs, + self.trainer.max_epochs, + self.trainer.estimated_stepping_batches // self.trainer.max_epochs, + self.init_lr, + self.max_lr, + self.final_lr, + ) + lr_sched_config = { + "scheduler": lr_sched, + "interval": "step" if isinstance(lr_sched, NoamLR) else "batch", + } + + return {"optimizer": opt, "lr_scheduler": lr_sched_config} + + @classmethod + def load_submodules(cls, checkpoint_path, **kwargs): + hparams = torch.load(checkpoint_path)["hyper_parameters"] + + kwargs |= { + key: hparams[key].pop("cls")(**hparams[key]) + for key in ("message_passing", "agg", "predictor") + if key not in kwargs + } + return kwargs + + @classmethod + def load_from_checkpoint( + cls, checkpoint_path, map_location=None, hparams_file=None, strict=True, **kwargs + ) -> MPNN: + kwargs = cls.load_submodules(checkpoint_path, **kwargs) + return super().load_from_checkpoint( + checkpoint_path, map_location, hparams_file, strict, **kwargs + ) + + @classmethod + def load_from_file(cls, model_path, map_location=None, strict=True) -> MPNN: + d = torch.load(model_path, map_location=map_location) + + try: + hparams = d["hyper_parameters"] + state_dict = d["state_dict"] + except KeyError: + raise KeyError(f"Could not find hyper parameters and/or state dict in {model_path}. ") + + for key in ["message_passing", "agg", "predictor"]: + hparam_kwargs = hparams[key] + hparam_cls = hparam_kwargs.pop("cls") + hparams[key] = hparam_cls(**hparam_kwargs) + + model = cls(**hparams) + model.load_state_dict(state_dict, strict=strict) + + return model diff --git a/chemprop/models/multi.py b/chemprop/models/multi.py new file mode 100644 index 0000000000000000000000000000000000000000..5d7f0d944b44ba9c3c71614e4c8c596225895f26 --- /dev/null +++ b/chemprop/models/multi.py @@ -0,0 +1,92 @@ +from typing import Iterable + +import torch +from torch import Tensor + +from chemprop.data import BatchMolGraph +from chemprop.nn import MulticomponentMessagePassing, Aggregation, Predictor +from chemprop.models.model import MPNN +from chemprop.nn.metrics import Metric +from chemprop.nn.transforms import ScaleTransform + + +class MulticomponentMPNN(MPNN): + def __init__( + self, + message_passing: MulticomponentMessagePassing, + agg: Aggregation, + predictor: Predictor, + batch_norm: bool = True, + metrics: Iterable[Metric] | None = None, + warmup_epochs: int = 2, + init_lr: float = 1e-4, + max_lr: float = 1e-3, + final_lr: float = 1e-4, + X_d_transform: ScaleTransform | None = None, + ): + super().__init__( + message_passing, + agg, + predictor, + batch_norm, + metrics, + warmup_epochs, + init_lr, + max_lr, + final_lr, + X_d_transform, + ) + self.message_passing: MulticomponentMessagePassing + + def fingerprint( + self, + bmgs: Iterable[BatchMolGraph], + V_ds: Iterable[Tensor | None], + X_d: Tensor | None = None, + ) -> Tensor: + H_vs: list[Tensor] = self.message_passing(bmgs, V_ds) + Hs = [self.agg(H_v, bmg.batch) for H_v, bmg in zip(H_vs, bmgs)] + H = torch.cat(Hs, 1) + H = self.bn(H) + + return H if X_d is None else torch.cat((H, self.X_d_transform(X_d)), 1) + + @classmethod + def load_submodules(cls, checkpoint_path, **kwargs): + hparams = torch.load(checkpoint_path)["hyper_parameters"] + + hparams["message_passing"]["blocks"] = [ + block_hparams.pop("cls")(**block_hparams) + for block_hparams in hparams["message_passing"]["blocks"] + ] + kwargs |= { + key: hparams[key].pop("cls")(**hparams[key]) + for key in ("message_passing", "agg", "predictor") + if key not in kwargs + } + return kwargs + + @classmethod + def load_from_file(cls, model_path, map_location=None, strict=True) -> MPNN: + d = torch.load(model_path, map_location=map_location) + + try: + hparams = d["hyper_parameters"] + state_dict = d["state_dict"] + except KeyError: + raise KeyError(f"Could not find hyper parameters and/or state dict in {model_path}. ") + + for key in ["message_passing", "agg", "predictor"]: + hparam_kwargs = hparams[key] + if key == "message_passing": + hparam_kwargs["blocks"] = [ + block_hparams.pop("cls")(**block_hparams) + for block_hparams in hparam_kwargs["blocks"] + ] + hparam_cls = hparam_kwargs.pop("cls") + hparams[key] = hparam_cls(**hparam_kwargs) + + model = cls(**hparams) + model.load_state_dict(state_dict, strict=strict) + + return model diff --git a/chemprop/models/utils.py b/chemprop/models/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..469f497b34f885c1646f3364405b5304654e9a8c --- /dev/null +++ b/chemprop/models/utils.py @@ -0,0 +1,18 @@ +from os import PathLike +import torch + +from chemprop.models.model import MPNN +from chemprop.models.multi import MulticomponentMPNN + + +def save_model(path: PathLike, model: MPNN) -> None: + torch.save({"hyper_parameters": model.hparams, "state_dict": model.state_dict()}, path) + + +def load_model(path: PathLike, multicomponent: bool) -> MPNN: + if multicomponent: + model = MulticomponentMPNN.load_from_file(path) + else: + model = MPNN.load_from_file(path) + + return model diff --git a/chemprop/nn/__init__.py b/chemprop/nn/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e8ed2c733d1d6353653488179fcc9b0e27d037b5 --- /dev/null +++ b/chemprop/nn/__init__.py @@ -0,0 +1,133 @@ +from .agg import ( + Aggregation, + AggregationRegistry, + MeanAggregation, + SumAggregation, + NormAggregation, + AttentiveAggregation, +) +from .loss import ( + LossFunction, + LossFunctionRegistry, + MSELoss, + BoundedMSELoss, + MVELoss, + EvidentialLoss, + BCELoss, + CrossEntropyLoss, + MccMixin, + BinaryMCCLoss, + MulticlassMCCLoss, + DirichletMixin, + BinaryDirichletLoss, + MulticlassDirichletLoss, + SIDLoss, + WassersteinLoss, +) +from .metrics import ( + Metric, + MetricRegistry, + ThresholdedMixin, + MAEMetric, + MSEMetric, + RMSEMetric, + BoundedMixin, + BoundedMAEMetric, + BoundedMSEMetric, + BoundedRMSEMetric, + R2Metric, + BinaryAUROCMetric, + BinaryAUPRCMetric, + BinaryAccuracyMetric, + BinaryF1Metric, + BCEMetric, + CrossEntropyMetric, + BinaryMCCMetric, + MulticlassMCCMetric, + SIDMetric, + WassersteinMetric, +) +from .message_passing import ( + MessagePassing, + AtomMessagePassing, + BondMessagePassing, + MulticomponentMessagePassing, +) +from .predictors import ( + Predictor, + PredictorRegistry, + RegressionFFN, + MveFFN, + EvidentialFFN, + BinaryClassificationFFNBase, + BinaryClassificationFFN, + BinaryDirichletFFN, + MulticlassClassificationFFN, + MulticlassDirichletFFN, + SpectralFFN, +) +from .utils import Activation +from .transforms import UnscaleTransform + +__all__ = [ + "Aggregation", + "AggregationRegistry", + "MeanAggregation", + "SumAggregation", + "NormAggregation", + "AttentiveAggregation", + "LossFunction", + "LossFunctionRegistry", + "MSELoss", + "BoundedMSELoss", + "MVELoss", + "EvidentialLoss", + "BCELoss", + "CrossEntropyLoss", + "MccMixin", + "BinaryMCCLoss", + "MulticlassMCCLoss", + "DirichletMixin", + "BinaryDirichletLoss", + "MulticlassDirichletLoss", + "SIDLoss", + "WassersteinLoss", + "Metric", + "MetricRegistry", + "ThresholdedMixin", + "MAEMetric", + "MSEMetric", + "RMSEMetric", + "BoundedMixin", + "BoundedMAEMetric", + "BoundedMSEMetric", + "BoundedRMSEMetric", + "R2Metric", + "BinaryAUROCMetric", + "BinaryAUPRCMetric", + "BinaryAccuracyMetric", + "BinaryF1Metric", + "BCEMetric", + "CrossEntropyMetric", + "BinaryMCCMetric", + "MulticlassMCCMetric", + "SIDMetric", + "WassersteinMetric", + "MessagePassing", + "AtomMessagePassing", + "BondMessagePassing", + "MulticomponentMessagePassing", + "Predictor", + "PredictorRegistry", + "RegressionFFN", + "MveFFN", + "EvidentialFFN", + "BinaryClassificationFFNBase", + "BinaryClassificationFFN", + "BinaryDirichletFFN", + "MulticlassClassificationFFN", + "MulticlassDirichletFFN", + "SpectralFFN", + "Activation", + "UnscaleTransform", +] diff --git a/chemprop/nn/agg.py b/chemprop/nn/agg.py new file mode 100644 index 0000000000000000000000000000000000000000..d11c36df3a9b0e887618cdb6aaa98270b4fc2d66 --- /dev/null +++ b/chemprop/nn/agg.py @@ -0,0 +1,132 @@ +from abc import abstractmethod +import torch +from torch import Tensor, nn + +from chemprop.utils import ClassRegistry +from chemprop.nn.hparams import HasHParams + + +__all__ = [ + "Aggregation", + "AggregationRegistry", + "MeanAggregation", + "SumAggregation", + "NormAggregation", + "AttentiveAggregation", +] + + +class Aggregation(nn.Module, HasHParams): + """An :class:`Aggregation` aggregates the node-level representations of a batch of graphs into + a batch of graph-level representations + + .. note:: + this class is abstract and cannot be instantiated. + + See also + -------- + :class:`~chemprop.v2.models.modules.agg.MeanAggregation` + :class:`~chemprop.v2.models.modules.agg.SumAggregation` + :class:`~chemprop.v2.models.modules.agg.NormAggregation` + """ + + def __init__(self, dim: int = 0, *args, **kwargs): + super().__init__() + + self.dim = dim + self.hparams = {"dim": dim, "cls": self.__class__} + + @abstractmethod + def forward(self, H: Tensor, batch: Tensor) -> Tensor: + """Aggregate the graph-level representations of a batch of graphs into their respective + global representations + + NOTE: it is possible for a graph to have 0 nodes. In this case, the representation will be + a zero vector of length `d` in the final output. + + Parameters + ---------- + H : Tensor + a tensor of shape ``V x d`` containing the batched node-level representations of ``b`` + graphs + batch : Tensor + a tensor of shape ``V`` containing the index of the graph a given vertex corresponds to + + Returns + ------- + Tensor + a tensor of shape ``b x d`` containing the graph-level representations + """ + + +AggregationRegistry = ClassRegistry[Aggregation]() + + +@AggregationRegistry.register("mean") +class MeanAggregation(Aggregation): + r"""Average the graph-level representation: + + .. math:: + \mathbf h = \frac{1}{|V|} \sum_{v \in V} \mathbf h_v + """ + + def forward(self, H: Tensor, batch: Tensor) -> Tensor: + index_torch = batch.unsqueeze(1).repeat(1, H.shape[1]) + dim_size = batch.max().int() + 1 + return torch.zeros(dim_size, H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_( + self.dim, index_torch, H, reduce="mean", include_self=False + ) + + +@AggregationRegistry.register("sum") +class SumAggregation(Aggregation): + r"""Sum the graph-level representation: + + .. math:: + \mathbf h = \sum_{v \in V} \mathbf h_v + + """ + + def forward(self, H: Tensor, batch: Tensor) -> Tensor: + index_torch = batch.unsqueeze(1).repeat(1, H.shape[1]) + dim_size = batch.max().int() + 1 + return torch.zeros(dim_size, H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_( + self.dim, index_torch, H, reduce="sum", include_self=False + ) + + +@AggregationRegistry.register("norm") +class NormAggregation(SumAggregation): + r"""Sum the graph-level representation and divide by a normalization constant: + + .. math:: + \mathbf h = \frac{1}{c} \sum_{v \in V} \mathbf h_v + """ + + def __init__(self, dim: int = 0, *args, norm: float = 100.0, **kwargs): + super().__init__(dim, **kwargs) + + self.norm = norm + self.hparams["norm"] = norm + + def forward(self, H: Tensor, batch: Tensor) -> Tensor: + return super().forward(H, batch) / self.norm + + +class AttentiveAggregation(Aggregation): + def __init__(self, dim: int = 0, *args, output_size: int, **kwargs): + super().__init__(dim, *args, **kwargs) + + self.W = nn.Linear(output_size, 1) + + def forward(self, H: Tensor, batch: Tensor) -> Tensor: + dim_size = batch.max().int() + 1 + attention_logits = self.W(H).exp() + Z = torch.zeros(dim_size, 1, dtype=H.dtype, device=H.device).scatter_reduce_( + self.dim, batch.unsqueeze(1), attention_logits, reduce="sum", include_self=False + ) + alphas = attention_logits / Z[batch] + index_torch = batch.unsqueeze(1).repeat(1, H.shape[1]) + return torch.zeros(dim_size, H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_( + self.dim, index_torch, alphas * H, reduce="sum", include_self=False + ) diff --git a/chemprop/nn/ffn.py b/chemprop/nn/ffn.py new file mode 100644 index 0000000000000000000000000000000000000000..3d69a5d55115454067ac7312fdace2c556540072 --- /dev/null +++ b/chemprop/nn/ffn.py @@ -0,0 +1,63 @@ +from abc import abstractmethod + +from torch import nn, Tensor + +from chemprop.nn.utils import get_activation_function + + +class FFN(nn.Module): + r"""A :class:`FFN` is a differentiable function + :math:`f_\theta : \mathbb R^i \mapsto \mathbb R^o`""" + + input_dim: int + output_dim: int + + @abstractmethod + def forward(self, X: Tensor) -> Tensor: + pass + + +class MLP(nn.Sequential, FFN): + r"""An :class:`MLP` is an FFN that implements the following function: + + .. math:: + \mathbf h_0 &= \mathbf W_0 \mathbf x \,+ \mathbf b_{0} \\ + \mathbf h_l &= \mathbf W_l \left( \mathtt{dropout} \left( \sigma ( \,\mathbf h_{l-1}\, ) \right) \right) + \mathbf b_l\\ + + where :math:`\mathbf x` is the input tensor, :math:`\mathbf W_l` and :math:`\mathbf b_l` + are the learned weight matrix and bias, respectively, of the :math:`l`-th layer, + :math:`\mathbf h_l` is the hidden representation after layer :math:`l`, and :math:`\sigma` + is the activation function. + """ + + @classmethod + def build( + cls, + input_dim: int, + output_dim: int, + hidden_dim: int = 300, + n_layers: int = 1, + dropout: float = 0.0, + activation: str = "relu", + ): + dropout = nn.Dropout(dropout) + act = get_activation_function(activation) + dims = [input_dim] + [hidden_dim] * n_layers + [output_dim] + blocks = [nn.Sequential(nn.Linear(dims[0], dims[1]))] + if len(dims) > 2: + blocks.extend( + [ + nn.Sequential(act, dropout, nn.Linear(d1, d2)) + for d1, d2 in zip(dims[1:-1], dims[2:]) + ] + ) + + return cls(*blocks) + + @property + def input_dim(self) -> int: + return self[0][-1].in_features + + @property + def output_dim(self) -> int: + return self[-1][-1].out_features diff --git a/chemprop/nn/hparams.py b/chemprop/nn/hparams.py new file mode 100644 index 0000000000000000000000000000000000000000..5cf5f5e1f47dd67e95ceb3c55121baa09e7c8642 --- /dev/null +++ b/chemprop/nn/hparams.py @@ -0,0 +1,38 @@ +from typing import Protocol, Type, TypedDict + + +class HParamsDict(TypedDict): + """A dictionary containing a module's class and it's hyperparameters + + Using this type should essentially allow for initializing a module via:: + + module = hparams.pop('cls')(**hparams) + """ + + cls: Type + + +class HasHParams(Protocol): + """:class:`HasHParams` is a protocol for clases which possess an :attr:`hparams` attribute which is a dictionary containing the object's class and arguments required to initialize it. + + That is, any object which implements :class:`HasHParams` should be able to be initialized via:: + + class Foo(HasHParams): + def __init__(self, *args, **kwargs): + ... + + foo1 = Foo(...) + foo1_cls = foo1.hparams['cls'] + foo1_kwargs = {k: v for k, v in foo1.hparams.items() if k != "cls"} + foo2 = foo1_cls(**foo1_kwargs) + # code to compare foo1 and foo2 goes here and they should be equal + """ + + hparams: HParamsDict + + +def from_hparams(hparams: HParamsDict): + cls = hparams["cls"] + kwargs = {k: v for k, v in hparams.items() if k != "cls"} + + return cls(**kwargs) diff --git a/chemprop/nn/loss.py b/chemprop/nn/loss.py new file mode 100644 index 0000000000000000000000000000000000000000..069fdabcc08ecc47d01f782afb8f7dac114763fe --- /dev/null +++ b/chemprop/nn/loss.py @@ -0,0 +1,344 @@ +from abc import abstractmethod +import torch +from torch import Tensor, nn +from torch.nn import functional as F +from numpy.typing import ArrayLike + +from chemprop.utils import ClassRegistry + + +__all__ = [ + "LossFunction", + "LossFunctionRegistry", + "MSELoss", + "BoundedMSELoss", + "MVELoss", + "EvidentialLoss", + "BCELoss", + "CrossEntropyLoss", + "MccMixin", + "BinaryMCCLoss", + "MulticlassMCCLoss", + "DirichletMixin", + "BinaryDirichletLoss", + "MulticlassDirichletLoss", + "SIDLoss", + "WassersteinLoss", +] + + +class LossFunction(nn.Module): + def __init__(self, task_weights: ArrayLike = 1.0): + """ + Parameters + ---------- + task_weights : ArrayLike, default=1.0 + the per-task weights of shape `t` or `1 x t`. Defaults to all tasks having a weight of 1. + """ + super().__init__() + task_weights = torch.as_tensor(task_weights, dtype=torch.float).view(1, -1) + self.register_buffer("task_weights", task_weights) + + def forward( + self, + preds: Tensor, + targets: Tensor, + mask: Tensor, + weights: Tensor, + lt_mask: Tensor, + gt_mask: Tensor, + ): + """Calculate the mean loss function value given predicted and target values + + Parameters + ---------- + preds : Tensor + a tensor of shape `b x (t * s)` (regression), `b x t` (binary classification), or + `b x t x c` (multiclass classification) containing the predictions, where `b` is the + batch size, `t` is the number of tasks to predict, `s` is the number of + targets to predict for each task, and `c` is the number of classes. + targets : Tensor + a float tensor of shape `b x t` containing the target values + mask : Tensor + a boolean tensor of shape `b x t` indicating whether the given prediction should be + included in the loss calculation + weights : Tensor + a tensor of shape `b` or `b x 1` containing the per-sample weight + lt_mask: Tensor + gt_mask: Tensor + + Returns + ------- + Tensor + a scalar containing the fully reduced loss + """ + L = self._calc_unreduced_loss(preds, targets, mask, weights, lt_mask, gt_mask) + L = L * weights.view(-1, 1) * self.task_weights.view(1, -1) * mask + + return L.sum() / mask.sum() + + @abstractmethod + def _calc_unreduced_loss(self, preds, targets, mask, weights, lt_mask, gt_mask) -> Tensor: + """Calculate a tensor of shape `b x t` containing the unreduced loss values.""" + + def extra_repr(self) -> str: + return f"task_weights={self.task_weights.tolist()}" + + +LossFunctionRegistry = ClassRegistry[LossFunction]() + + +@LossFunctionRegistry.register("mse") +class MSELoss(LossFunction): + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor: + return F.mse_loss(preds, targets, reduction="none") + + +@LossFunctionRegistry.register("bounded-mse") +class BoundedMSELoss(MSELoss): + def _calc_unreduced_loss( + self, preds: Tensor, targets: Tensor, mask, weights, lt_mask: Tensor, gt_mask: Tensor + ) -> Tensor: + preds = torch.where((preds < targets) & lt_mask, targets, preds) + preds = torch.where((preds > targets) & gt_mask, targets, preds) + + return super()._calc_unreduced_loss(preds, targets) + + +@LossFunctionRegistry.register("mve") +class MVELoss(LossFunction): + """Calculate the loss using Eq. 9 from [nix1994]_ + + References + ---------- + .. [nix1994] Nix, D. A.; Weigend, A. S. "Estimating the mean and variance of the target + probability distribution." Proceedings of 1994 IEEE International Conference on Neural + Networks, 1994 https://doi.org/10.1109/icnn.1994.374138 + """ + + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor: + mean, var = torch.chunk(preds, 2, 1) + + L_sos = (mean - targets) ** 2 / (2 * var) + L_kl = (2 * torch.pi * var).log() / 2 + + return L_sos + L_kl + + +@LossFunctionRegistry.register("evidential") +class EvidentialLoss(LossFunction): + """Calculate the loss using Eqs. 8, 9, and 10 from [amini2020]_ + + References + ---------- + .. [amini2020] Amini, A; Schwarting, W.; Soleimany, A.; Rus, D.; + "Deep Evidential Regression" Advances in Neural Information Processing Systems;2020; Vol.33. + https://proceedings.neurips.cc/paper_files/paper/2020/file/aab085461de182608ee9f607f3f7d18f-Paper.pdf + .. [soleimany2021] Soleimany, A.P.; Amini, A.; Goldman, S.; Rus, D.; Bhatia, S.N.; Coley, C.W.; + "Evidential Deep Learning for Guided Molecular Property Prediction and Discovery." ACS + Cent. Sci. 2021, 7, 8, 1356-1367. https://doi.org/10.1021/acscentsci.1c00546 + """ + + def __init__(self, task_weights: Tensor | None = None, v_kl: float = 0.2, eps: float = 1e-8): + super().__init__(task_weights) + self.v_kl = v_kl + self.eps = eps + + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor: + mean, v, alpha, beta = torch.chunk(preds, 4, 1) + + residuals = targets - mean + twoBlambda = 2 * beta * (1 + v) + + L_nll = ( + 0.5 * (torch.pi / v).log() + - alpha * twoBlambda.log() + + (alpha + 0.5) * torch.log(v * residuals**2 + twoBlambda) + + torch.lgamma(alpha) + - torch.lgamma(alpha + 0.5) + ) + + L_reg = (2 * v + alpha) * residuals.abs() + + return L_nll + self.v_kl * (L_reg - self.eps) + + def extra_repr(self) -> str: + parent_repr = super().extra_repr() + return parent_repr + f", v_kl={self.v_kl}, eps={self.eps}" + + +@LossFunctionRegistry.register("bce") +class BCELoss(LossFunction): + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor: + return F.binary_cross_entropy_with_logits(preds, targets, reduction="none") + + +@LossFunctionRegistry.register("ce") +class CrossEntropyLoss(LossFunction): + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor: + preds = preds.transpose(1, 2) + targets = targets.long() + + return F.cross_entropy(preds, targets, reduction="none") + + +class MccMixin: + """Calculate a soft Matthews correlation coefficient ([mccWiki]_) loss for multiclass + classification based on the implementataion of [mccSklearn]_ + + References + ---------- + .. [mccWiki] https://en.wikipedia.org/wiki/Phi_coefficient#Multiclass_case + .. [mccSklearn] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.matthews_corrcoef.html + """ + + def __call__(self, preds: Tensor, targets: Tensor, mask: Tensor, weights: Tensor, *args): + if not (0 <= preds.min() and preds.max() <= 1): # assume logits + preds = preds.softmax(2) + + L = self._calc_unreduced_loss(preds, targets.long(), mask, weights, *args) + L = L * self.task_weights + + return L.mean() + + +@LossFunctionRegistry.register("binary-mcc") +class BinaryMCCLoss(LossFunction, MccMixin): + def _calc_unreduced_loss(self, preds, targets, mask, weights, *args) -> Tensor: + TP = (targets * preds * weights * mask).sum(0, keepdim=True) + FP = ((1 - targets) * preds * weights * mask).sum(0, keepdim=True) + TN = ((1 - targets) * (1 - preds) * weights * mask).sum(0, keepdim=True) + FN = (targets * (1 - preds) * weights * mask).sum(0, keepdim=True) + + MCC = (TP * TN - FP * FN) / ((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)).sqrt() + + return 1 - MCC + + +@LossFunctionRegistry.register("multiclass-mcc") +class MulticlassMCCLoss(LossFunction, MccMixin): + def _calc_unreduced_loss(self, preds, targets, mask, weights, *args) -> Tensor: + device = preds.device + + C = preds.shape[2] + bin_targets = torch.eye(C, device=device)[targets] + bin_preds = torch.eye(C, device=device)[preds.argmax(-1)] + masked_data_weights = weights.unsqueeze(2) * mask.unsqueeze(2) + + p = (bin_preds * masked_data_weights).sum(0) + t = (bin_targets * masked_data_weights).sum(0) + c = (bin_preds * bin_targets * masked_data_weights).sum() + s = (preds * masked_data_weights).sum() + s2 = s.square() + + # the `einsum` calls amount to calculating the batched dot product + cov_ytyp = c * s - torch.einsum("ij,ij->i", p, t).sum() + cov_ypyp = s2 - torch.einsum("ij,ij->i", p, p).sum() + cov_ytyt = s2 - torch.einsum("ij,ij->i", t, t).sum() + + x = cov_ypyp * cov_ytyt + MCC = torch.tensor(0.0, device=device) if x == 0 else cov_ytyp / x.sqrt() + + return 1 - MCC + + +class DirichletMixin: + """Uses the loss function from [sensoy2018]_ based on the implementation at [sensoyGithub]_ + + References + ---------- + .. [sensoy2018] Sensoy, M.; Kaplan, L.; Kandemir, M. "Evidential deep learning to quantify + classification uncertainty." NeurIPS, 2018, 31. https://doi.org/10.48550/arXiv.1806.01768 + .. [sensoyGithub] https://muratsensoy.github.io/uncertainty.html#Define-the-loss-function + """ + + def __init__(self, task_weights: Tensor | None = None, v_kl: float = 0.2): + super().__init__(task_weights) + self.v_kl = v_kl + + def _calc_unreduced_loss(self, preds, targets, *args) -> Tensor: + S = preds.sum(-1, keepdim=True) + p = preds / S + + A = (targets - p).square().sum(-1, keepdim=True) + B = ((p * (1 - p)) / (S + 1)).sum(-1, keepdim=True) + + L_mse = A + B + + alpha = targets + (1 - targets) * preds + beta = torch.ones_like(alpha) + S_alpha = alpha.sum(-1, keepdim=True) + S_beta = beta.sum(-1, keepdim=True) + + ln_alpha = S_alpha.lgamma() - alpha.lgamma().sum(-1, keepdim=True) + ln_beta = beta.lgamma().sum(-1, keepdim=True) - S_beta.lgamma() + + dg0 = torch.digamma(alpha) + dg1 = torch.digamma(S_alpha) + + L_kl = ln_alpha + ln_beta + torch.sum((alpha - beta) * (dg0 - dg1), -1, keepdim=True) + + return (L_mse + self.v_kl * L_kl).mean(-1) + + def extra_repr(self) -> str: + return f"v_kl={self.v_kl}" + + +@LossFunctionRegistry.register("binary-dirichlet") +class BinaryDirichletLoss(DirichletMixin, LossFunction): + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor: + N_CLASSES = 2 + n_tasks = targets.shape[1] + preds = preds.reshape(len(preds), n_tasks, N_CLASSES) + y_one_hot = torch.eye(N_CLASSES, device=preds.device)[targets.long()] + + return super()._calc_unreduced_loss(preds, y_one_hot, *args) + + +@LossFunctionRegistry.register("multiclass-dirichlet") +class MulticlassDirichletLoss(DirichletMixin, LossFunction): + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, mask: Tensor, *args) -> Tensor: + y_one_hot = torch.eye(preds.shape[2], device=preds.device)[targets.long()] + + return super()._calc_unreduced_loss(preds, y_one_hot, mask) + + +@LossFunctionRegistry.register("sid") +class SIDLoss(LossFunction): + def __init__(self, task_weights: Tensor | None = None, threshold: float | None = None): + super().__init__(task_weights) + + self.threshold = threshold + + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, mask: Tensor, *args) -> Tensor: + if self.threshold is not None: + preds = preds.clamp(min=self.threshold) + + preds_norm = preds / (preds * mask).sum(1, keepdim=True) + + targets = targets.masked_fill(~mask, 1) + preds_norm = preds_norm.masked_fill(~mask, 1) + + return (preds_norm / targets).log() * preds_norm + (targets / preds_norm).log() * targets + + def extra_repr(self) -> str: + return f"threshold={self.threshold}" + + +@LossFunctionRegistry.register(["earthmovers", "wasserstein"]) +class WassersteinLoss(LossFunction): + def __init__(self, task_weights: Tensor | None = None, threshold: float | None = None): + super().__init__(task_weights) + + self.threshold = threshold + + def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, mask: Tensor, *args) -> Tensor: + if self.threshold is not None: + preds = preds.clamp(min=self.threshold) + + preds_norm = preds / (preds * mask).sum(1, keepdim=True) + + return (targets.cumsum(1) - preds_norm.cumsum(1)).abs() + + def extra_repr(self) -> str: + return f"threshold={self.threshold}" diff --git a/chemprop/nn/message_passing/__init__.py b/chemprop/nn/message_passing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e07ec459979d1865777d0fdc83c5ac01dfae9aab --- /dev/null +++ b/chemprop/nn/message_passing/__init__.py @@ -0,0 +1,10 @@ +from .proto import MessagePassing +from .base import AtomMessagePassing, BondMessagePassing +from .multi import MulticomponentMessagePassing + +__all__ = [ + "MessagePassing", + "AtomMessagePassing", + "BondMessagePassing", + "MulticomponentMessagePassing", +] diff --git a/chemprop/nn/message_passing/base.py b/chemprop/nn/message_passing/base.py new file mode 100644 index 0000000000000000000000000000000000000000..7f828ec66a74a4e97e05eec7df70d41d23275f1b --- /dev/null +++ b/chemprop/nn/message_passing/base.py @@ -0,0 +1,311 @@ +from abc import abstractmethod + +from lightning.pytorch.core.mixins import HyperparametersMixin +import torch +from torch import Tensor, nn + +from chemprop.conf import DEFAULT_ATOM_FDIM, DEFAULT_BOND_FDIM, DEFAULT_HIDDEN_DIM +from chemprop.exceptions import InvalidShapeError +from chemprop.data import BatchMolGraph +from chemprop.nn.utils import Activation, get_activation_function +from chemprop.nn.message_passing.proto import MessagePassing +from chemprop.nn.transforms import ScaleTransform, GraphTransform + + +class _MessagePassingBase(MessagePassing, HyperparametersMixin): + """The base message-passing block for atom- and bond-based message-passing schemes + + NOTE: this class is an abstract base class and cannot be instantiated + + Parameters + ---------- + d_v : int, default=DEFAULT_ATOM_FDIM + the feature dimension of the vertices + d_e : int, default=DEFAULT_BOND_FDIM + the feature dimension of the edges + d_h : int, default=DEFAULT_HIDDEN_DIM + the hidden dimension during message passing + bias : bool, defuault=False + if `True`, add a bias term to the learned weight matrices + depth : int, default=3 + the number of message passing iterations + undirected : bool, default=False + if `True`, pass messages on undirected edges + dropout : float, default=0.0 + the dropout probability + activation : str, default="relu" + the activation function to use + d_vd : int | None, default=None + the dimension of additional vertex descriptors that will be concatenated to the hidden features before readout + + See also + -------- + * :class:`AtomMessagePassing` + + * :class:`BondMessagePassing` + """ + + def __init__( + self, + d_v: int = DEFAULT_ATOM_FDIM, + d_e: int = DEFAULT_BOND_FDIM, + d_h: int = DEFAULT_HIDDEN_DIM, + bias: bool = False, + depth: int = 3, + dropout: float = 0.0, + activation: str | Activation = Activation.RELU, + undirected: bool = False, + d_vd: int | None = None, + V_d_transform: ScaleTransform | None = None, + graph_transform: GraphTransform | None = None, + # layers_per_message: int = 1, + ): + super().__init__() + self.save_hyperparameters() + self.hparams["cls"] = self.__class__ + + self.W_i, self.W_h, self.W_o, self.W_d = self.setup(d_v, d_e, d_h, d_vd, bias) + self.depth = depth + self.undirected = undirected + self.dropout = nn.Dropout(dropout) + self.tau = get_activation_function(activation) + self.V_d_transform = V_d_transform if V_d_transform is not None else nn.Identity() + self.graph_transform = graph_transform if graph_transform is not None else nn.Identity() + + @property + def output_dim(self) -> int: + return self.W_d.out_features if self.W_d is not None else self.W_o.out_features + + @abstractmethod + def setup( + self, + d_v: int = DEFAULT_ATOM_FDIM, + d_e: int = DEFAULT_BOND_FDIM, + d_h: int = DEFAULT_HIDDEN_DIM, + d_vd: int | None = None, + bias: bool = False, + ) -> tuple[nn.Module, nn.Module, nn.Module, nn.Module | None]: + """setup the weight matrices used in the message passing update functions + + Parameters + ---------- + d_v : int + the vertex feature dimension + d_e : int + the edge feature dimension + d_h : int, default=300 + the hidden dimension during message passing + d_vd : int | None, default=None + the dimension of additional vertex descriptors that will be concatenated to the hidden + features before readout, if any + bias: bool, default=False + whether to add a learned bias to the matrices + + Returns + ------- + W_i, W_h, W_o, W_d : tuple[nn.Module, nn.Module, nn.Module, nn.Module | None] + the input, hidden, output, and descriptor weight matrices, respectively, used in the + message passing update functions. The descriptor weight matrix is `None` if no vertex + dimension is supplied + """ + + @abstractmethod + def initialize(self, bmg: BatchMolGraph) -> Tensor: + """initialize the message passing scheme by calculating initial matrix of hidden features""" + + @abstractmethod + def message(self, H_t: Tensor, bmg: BatchMolGraph): + """Calculate the message matrix""" + + def update(self, M_t, H_0): + """Calcualte the updated hidden for each edge""" + H_t = self.W_h(M_t) + H_t = self.tau(H_0 + H_t) + H_t = self.dropout(H_t) + + return H_t + + def finalize(self, M: Tensor, V: Tensor, V_d: Tensor | None) -> Tensor: + r"""Finalize message passing by (1) concatenating the final message ``M`` and the original + vertex features ``V`` and (2) if provided, further concatenating additional vertex + descriptors ``V_d``. + + This function implements the following operation: + + .. math:: + H &= \mathtt{dropout} \left( \tau(\mathbf{W}_o(V \mathbin\Vert M)) \right) \\ + H &= \mathtt{dropout} \left( \tau(\mathbf{W}_d(H \mathbin\Vert V_d)) \right), + + where :math:`\tau` is the activation function, :math:`\Vert` is the concatenation operator, + :math:`\mathbf{W}_o` and :math:`\mathbf{W}_d` are learned weight matrices, :math:`M` is + the message matrix, :math:`V` is the original vertex feature matrix, and :math:`V_d` is an + optional vertex descriptor matrix. + + Parameters + ---------- + M : Tensor + a tensor of shape ``V x d_h`` containing the message vector of each vertex + V : Tensor + a tensor of shape ``V x d_v`` containing the original vertex features + V_d : Tensor | None + an optional tensor of shape ``V x d_vd`` containing additional vertex descriptors + + Returns + ------- + Tensor + a tensor of shape ``V x (d_h + d_v [+ d_vd])`` containing the final hidden + representations + + Raises + ------ + InvalidShapeError + if ``V_d`` is not of shape ``b x d_vd``, where ``b`` is the batch size and ``d_vd`` is + the vertex descriptor dimension + """ + H = self.W_o(torch.cat((V, M), dim=1)) # V x d_o + H = self.tau(H) + H = self.dropout(H) + + if V_d is not None: + V_d = self.V_d_transform(V_d) + try: + H = self.W_d(torch.cat((H, V_d), dim=1)) # V x (d_o + d_vd) + H = self.dropout(H) + except RuntimeError: + raise InvalidShapeError("V_d", V_d.shape, [len(H), self.W_d.in_features]) + + return H + + def forward(self, bmg: BatchMolGraph, V_d: Tensor | None = None) -> Tensor: + """Encode a batch of molecular graphs. + + Parameters + ---------- + bmg: BatchMolGraph + a batch of :class:`BatchMolGraph`s to encode + V_d : Tensor | None, default=None + an optional tensor of shape ``V x d_vd`` containing additional descriptors for each atom + in the batch. These will be concatenated to the learned atomic descriptors and + transformed before the readout phase. + + Returns + ------- + Tensor + a tensor of shape ``V x d_h`` or ``V x (d_h + d_vd)`` containing the encoding of each + molecule in the batch, depending on whether additional atom descriptors were provided + """ + bmg = self.graph_transform(bmg) + H_0 = self.initialize(bmg) + + H = self.tau(H_0) + for _ in range(1, self.depth): + if self.undirected: + H = (H + H[bmg.rev_edge_index]) / 2 + + M = self.message(H, bmg) + H = self.update(M, H_0) + + index_torch = bmg.edge_index[1].unsqueeze(1).repeat(1, H.shape[1]) + M = torch.zeros(len(bmg.V), H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_( + 0, index_torch, H, reduce="sum", include_self=False + ) + return self.finalize(M, bmg.V, V_d) + + +class BondMessagePassing(_MessagePassingBase): + r"""A :class:`BondMessagePassing` encodes a batch of molecular graphs by passing messages along + directed bonds. + + It implements the following operation: + + .. math:: + + h_{vw}^{(0)} &= \tau \left( \mathbf W_i(e_{vw}) \right) \\ + m_{vw}^{(t)} &= \sum_{u \in \mathcal N(v)\setminus w} h_{uv}^{(t-1)} \\ + h_{vw}^{(t)} &= \tau \left(h_v^{(0)} + \mathbf W_h m_{vw}^{(t-1)} \right) \\ + m_v^{(T)} &= \sum_{w \in \mathcal N(v)} h_w^{(T-1)} \\ + h_v^{(T)} &= \tau \left (\mathbf W_o \left( x_v \mathbin\Vert m_{v}^{(T)} \right) \right), + + where :math:`\tau` is the activation function; :math:`\mathbf W_i`, :math:`\mathbf W_h`, and + :math:`\mathbf W_o` are learned weight matrices; :math:`e_{vw}` is the feature vector of the + bond between atoms :math:`v` and :math:`w`; :math:`x_v` is the feature vector of atom :math:`v`; + :math:`h_{vw}^{(t)}` is the hidden representation of the bond :math:`v \rightarrow w` at + iteration :math:`t`; :math:`m_{vw}^{(t)}` is the message received by the bond :math:`v + \to w` at iteration :math:`t`; and :math:`t \in \{1, \dots, T-1\}` is the number of + message passing iterations. + """ + + def setup( + self, + d_v: int = DEFAULT_ATOM_FDIM, + d_e: int = DEFAULT_BOND_FDIM, + d_h: int = DEFAULT_HIDDEN_DIM, + d_vd: int | None = None, + bias: bool = False, + ): + W_i = nn.Linear(d_v + d_e, d_h, bias) + W_h = nn.Linear(d_h, d_h, bias) + W_o = nn.Linear(d_v + d_h, d_h) + W_d = nn.Linear(d_h + d_vd, d_h + d_vd) if d_vd is not None else None + + return W_i, W_h, W_o, W_d + + def initialize(self, bmg: BatchMolGraph) -> Tensor: + return self.W_i(torch.cat([bmg.V[bmg.edge_index[0]], bmg.E], dim=1)) + + def message(self, H: Tensor, bmg: BatchMolGraph) -> Tensor: + index_torch = bmg.edge_index[1].unsqueeze(1).repeat(1, H.shape[1]) + M_all = torch.zeros(len(bmg.V), H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_( + 0, index_torch, H, reduce="sum", include_self=False + )[bmg.edge_index[0]] + M_rev = H[bmg.rev_edge_index] + + return M_all - M_rev + + +class AtomMessagePassing(_MessagePassingBase): + r"""A :class:`AtomMessagePassing` encodes a batch of molecular graphs by passing messages along + atoms. + + It implements the following operation: + + .. math:: + + h_v^{(0)} &= \tau \left( \mathbf{W}_i(x_v) \right) \\ + m_v^{(t)} &= \sum_{u \in \mathcal{N}(v)} h_u^{(t-1)} \mathbin\Vert e_{uv} \\ + h_v^{(t)} &= \tau\left(h_v^{(0)} + \mathbf{W}_h m_v^{(t-1)}\right) \\ + m_v^{(T)} &= \sum_{w \in \mathcal{N}(v)} h_w^{(T-1)} \\ + h_v^{(T)} &= \tau \left (\mathbf{W}_o \left( x_v \mathbin\Vert m_{v}^{(T)} \right) \right), + + where :math:`\tau` is the activation function; :math:`\mathbf{W}_i`, :math:`\mathbf{W}_h`, and + :math:`\mathbf{W}_o` are learned weight matrices; :math:`e_{vw}` is the feature vector of the + bond between atoms :math:`v` and :math:`w`; :math:`x_v` is the feature vector of atom :math:`v`; + :math:`h_v^{(t)}` is the hidden representation of atom :math:`v` at iteration :math:`t`; + :math:`m_v^{(t)}` is the message received by atom :math:`v` at iteration :math:`t`; and + :math:`t \in \{1, \dots, T\}` is the number of message passing iterations. + """ + + def setup( + self, + d_v: int = DEFAULT_ATOM_FDIM, + d_e: int = DEFAULT_BOND_FDIM, + d_h: int = DEFAULT_HIDDEN_DIM, + d_vd: int | None = None, + bias: bool = False, + ): + W_i = nn.Linear(d_v, d_h, bias) + W_h = nn.Linear(d_e + d_h, d_h, bias) + W_o = nn.Linear(d_v + d_h, d_h) + W_d = nn.Linear(d_h + d_vd, d_h + d_vd) if d_vd is not None else None + + return W_i, W_h, W_o, W_d + + def initialize(self, bmg: BatchMolGraph) -> Tensor: + return self.W_i(bmg.V[bmg.edge_index[0]]) + + def message(self, H: Tensor, bmg: BatchMolGraph): + H = torch.cat((H, bmg.E), dim=1) + index_torch = bmg.edge_index[1].unsqueeze(1).repeat(1, H.shape[1]) + return torch.zeros(len(bmg.V), H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_( + 0, index_torch, H, reduce="sum", include_self=False + )[bmg.edge_index[0]] diff --git a/chemprop/nn/message_passing/multi.py b/chemprop/nn/message_passing/multi.py new file mode 100644 index 0000000000000000000000000000000000000000..5cd1b8c072f5ce537c00070d02b257856ab1b239 --- /dev/null +++ b/chemprop/nn/message_passing/multi.py @@ -0,0 +1,78 @@ +from typing import Iterable, Sequence +import warnings + +from torch import Tensor, nn + +from chemprop.data import BatchMolGraph +from chemprop.nn.message_passing.proto import MessagePassing +from chemprop.nn.hparams import HasHParams + + +class MulticomponentMessagePassing(nn.Module, HasHParams): + """A `MulticomponentMessagePassing` performs message-passing on each individual input in a + multicomponent input then concatenates the representation of each input to construct a + global representation + + Parameters + ---------- + blocks : Sequence[MessagePassing] + the invidual message-passing blocks for each input + n_components : int + the number of components in each input + shared : bool, default=False + whether one block will be shared among all components in an input. If not, a separate + block will be learned for each component. + """ + + def __init__(self, blocks: Sequence[MessagePassing], n_components: int, shared: bool = False): + super().__init__() + self.hparams = { + "cls": self.__class__, + "blocks": [block.hparams for block in blocks], + "n_components": n_components, + "shared": shared, + } + + if len(blocks) == 0: + raise ValueError("arg 'blocks' was empty!") + if shared and len(blocks) > 1: + warnings.warn( + "More than 1 block was supplied but 'shared' was True! Using only the 0th block..." + ) + elif not shared and len(blocks) != n_components: + raise ValueError( + "arg 'n_components' must be equal to `len(blocks)` if 'shared' is False! " + f"got: {n_components} and {len(blocks)}, respectively." + ) + + self.n_components = n_components + self.shared = shared + self.blocks = nn.ModuleList([blocks[0]] * self.n_components if shared else blocks) + + def __len__(self) -> int: + return len(self.blocks) + + @property + def output_dim(self) -> int: + d_o = sum(block.output_dim for block in self.blocks) + + return d_o + + def forward(self, bmgs: Iterable[BatchMolGraph], V_ds: Iterable[Tensor | None]) -> list[Tensor]: + """Encode the multicomponent inputs + + Parameters + ---------- + bmgs : Iterable[BatchMolGraph] + V_ds : Iterable[Tensor | None] + + Returns + ------- + list[Tensor] + a list of tensors of shape `V x d_i` containing the respective encodings of the `i`\th + component, where `d_i` is the output dimension of the `i`\th encoder + """ + if V_ds is None: + return [block(bmg) for block, bmg in zip(self.blocks, bmgs)] + else: + return [block(bmg, V_d) for block, bmg, V_d in zip(self.blocks, bmgs, V_ds)] diff --git a/chemprop/nn/message_passing/proto.py b/chemprop/nn/message_passing/proto.py new file mode 100644 index 0000000000000000000000000000000000000000..4c86106b92a50cd8c3cde8dd098911bd4460d077 --- /dev/null +++ b/chemprop/nn/message_passing/proto.py @@ -0,0 +1,35 @@ +from abc import abstractmethod + +from torch import nn, Tensor + +from chemprop.data import BatchMolGraph +from chemprop.nn.hparams import HasHParams + + +class MessagePassing(nn.Module, HasHParams): + """A :class:`MessagePassing` module encodes a batch of molecular graphs + using message passing to learn vertex-level hidden representations.""" + + input_dim: int + output_dim: int + + @abstractmethod + def forward(self, bmg: BatchMolGraph, V_d: Tensor | None = None) -> Tensor: + """Encode a batch of molecular graphs. + + Parameters + ---------- + bmg: BatchMolGraph + the batch of :class:`~chemprop.featurizers.molgraph.MolGraph`\s to encode + V_d : Tensor | None, default=None + an optional tensor of shape `V x d_vd` containing additional descriptors for each atom + in the batch. These will be concatenated to the learned atomic descriptors and + transformed before the readout phase. + + Returns + ------- + Tensor + a tensor of shape `V x d_h` or `V x (d_h + d_vd)` containing the hidden representation + of each vertex in the batch of graphs. The feature dimension depends on whether + additional atom descriptors were provided + """ diff --git a/chemprop/nn/metrics.py b/chemprop/nn/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..a8077d570f295e79c2f95beaa41d910ed577b96a --- /dev/null +++ b/chemprop/nn/metrics.py @@ -0,0 +1,209 @@ +from abc import abstractmethod +from dataclasses import dataclass + +import torch +from torch import Tensor +from torchmetrics import functional as F +from torchmetrics.utilities.compute import auc + +from chemprop.utils.registry import ClassRegistry +from chemprop.nn.loss import ( + BCELoss, + BinaryMCCLoss, + CrossEntropyLoss, + LossFunction, + MSELoss, + MulticlassMCCLoss, + SIDLoss, + WassersteinLoss, +) + +__all__ = [ + "Metric", + "MetricRegistry", + "ThresholdedMixin", + "MAEMetric", + "MSEMetric", + "RMSEMetric", + "BoundedMixin", + "BoundedMAEMetric", + "BoundedMSEMetric", + "BoundedRMSEMetric", + "R2Metric", + "BinaryAUROCMetric", + "BinaryAUPRCMetric", + "BinaryAccuracyMetric", + "BinaryF1Metric", + "BCEMetric", + "CrossEntropyMetric", + "BinaryMCCMetric", + "MulticlassMCCMetric", + "SIDMetric", + "WassersteinMetric", +] + + +class Metric(LossFunction): + """ + Parameters + ---------- + task_weights : ArrayLike = 1.0 + .. important:: + Ignored. Maintained for compatibility with :class:`~chemprop.nn.loss.LossFunction` + """ + + minimize: bool = True + + def forward( + self, + preds: Tensor, + targets: Tensor, + mask: Tensor, + weights: Tensor, + lt_mask: Tensor, + gt_mask: Tensor, + ): + return self._calc_unreduced_loss(preds, targets, mask, lt_mask, gt_mask)[mask].mean() + + @abstractmethod + def _calc_unreduced_loss(self, preds, targets, mask, lt_mask, gt_mask) -> Tensor: + pass + + +MetricRegistry = ClassRegistry[Metric]() + + +@dataclass +class ThresholdedMixin: + threshold: float | None = 0.5 + + def extra_repr(self) -> str: + return f"threshold={self.threshold}" + + +@MetricRegistry.register("mae") +class MAEMetric(Metric): + def _calc_unreduced_loss(self, preds, targets, *args) -> Tensor: + return (preds - targets).abs() + + +@MetricRegistry.register("mse") +class MSEMetric(MSELoss, Metric): + pass + + +@MetricRegistry.register("rmse") +class RMSEMetric(MSEMetric): + def forward( + self, + preds: Tensor, + targets: Tensor, + mask: Tensor, + weights: Tensor, + lt_mask: Tensor, + gt_mask: Tensor, + ): + squared_errors = super()._calc_unreduced_loss(preds, targets, mask, lt_mask, gt_mask) + + return squared_errors[mask].mean().sqrt() + + +class BoundedMixin: + def _calc_unreduced_loss(self, preds, targets, mask, lt_mask, gt_mask) -> Tensor: + preds = torch.where((preds < targets) & lt_mask, targets, preds) + preds = torch.where((preds > targets) & gt_mask, targets, preds) + + return super()._calc_unreduced_loss(preds, targets, mask, lt_mask, gt_mask) + + +@MetricRegistry.register("bounded-mae") +class BoundedMAEMetric(MAEMetric, BoundedMixin): + pass + + +@MetricRegistry.register("bounded-mse") +class BoundedMSEMetric(MSEMetric, BoundedMixin): + pass + + +@MetricRegistry.register("bounded-rmse") +class BoundedRMSEMetric(RMSEMetric, BoundedMixin): + pass + + +@MetricRegistry.register("r2") +class R2Metric(Metric): + minimize = False + + def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs): + return F.r2_score(preds[mask], targets[mask]) + + +@MetricRegistry.register("roc") +class BinaryAUROCMetric(Metric): + minimize = False + + def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs): + return self._calc_unreduced_loss(preds, targets, mask) + + def _calc_unreduced_loss(self, preds, targets, mask, *args) -> Tensor: + return F.auroc(preds[mask], targets[mask].long(), task="binary") + + +@MetricRegistry.register("prc") +class BinaryAUPRCMetric(Metric): + minimize = False + + def forward(self, preds: Tensor, targets: Tensor, *args, **kwargs): + p, r, _ = F.precision_recall_curve(preds, targets.long(), task="binary") + return auc(r, p) + + +@MetricRegistry.register("accuracy") +class BinaryAccuracyMetric(Metric, ThresholdedMixin): + minimize = False + + def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs): + return F.accuracy( + preds[mask], targets[mask].long(), threshold=self.threshold, task="binary" + ) + + +@MetricRegistry.register("f1") +class BinaryF1Metric(Metric, ThresholdedMixin): + minimize = False + + def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs): + return F.f1_score( + preds[mask], targets[mask].long(), threshold=self.threshold, task="binary" + ) + + +@MetricRegistry.register("bce") +class BCEMetric(BCELoss, Metric): + pass + + +@MetricRegistry.register("ce") +class CrossEntropyMetric(CrossEntropyLoss, Metric): + pass + + +@MetricRegistry.register("binary-mcc") +class BinaryMCCMetric(BinaryMCCLoss, Metric): + pass + + +@MetricRegistry.register("multiclass-mcc") +class MulticlassMCCMetric(MulticlassMCCLoss, Metric): + pass + + +@MetricRegistry.register("sid") +class SIDMetric(SIDLoss, Metric): + pass + + +@MetricRegistry.register("wasserstein") +class WassersteinMetric(WassersteinLoss, Metric): + pass diff --git a/chemprop/nn/predictors.py b/chemprop/nn/predictors.py new file mode 100644 index 0000000000000000000000000000000000000000..2661e67f34ddd35efcab2ec99313e2352aadb1ba --- /dev/null +++ b/chemprop/nn/predictors.py @@ -0,0 +1,348 @@ +from abc import abstractmethod + +from lightning.pytorch.core.mixins import HyperparametersMixin +import torch +from torch import nn, Tensor +from torch.nn import functional as F + +from chemprop.nn.loss import ( + BCELoss, + BinaryDirichletLoss, + CrossEntropyLoss, + EvidentialLoss, + LossFunction, + MSELoss, + MVELoss, + MulticlassDirichletLoss, + SIDLoss, +) +from chemprop.nn.metrics import BinaryAUROCMetric, CrossEntropyMetric, MSEMetric, Metric, SIDMetric +from chemprop.nn.ffn import MLP +from chemprop.nn.transforms import UnscaleTransform + +from chemprop.nn.hparams import HasHParams +from chemprop.conf import DEFAULT_HIDDEN_DIM +from chemprop.utils import ClassRegistry, Factory + +__all__ = [ + "Predictor", + "PredictorRegistry", + "RegressionFFN", + "MveFFN", + "EvidentialFFN", + "BinaryClassificationFFNBase", + "BinaryClassificationFFN", + "BinaryDirichletFFN", + "MulticlassClassificationFFN", + "MulticlassDirichletFFN", + "SpectralFFN", +] + + +class Predictor(nn.Module, HasHParams): + r"""A :class:`Predictor` is a protocol that defines a differentiable function + :math:`f` : \mathbb R^d \mapsto \mathbb R^o""" + + input_dim: int + """the input dimension""" + output_dim: int + """the output dimension""" + n_tasks: int + """the number of tasks `t` to predict for each input""" + n_targets: int + """the number of targets `s` to predict for each task `t`""" + criterion: LossFunction + """the loss function to use for training""" + task_weights: Tensor + """the weights to apply to each task when calculating the loss""" + output_transform: UnscaleTransform + """the transform to apply to the output of the predictor""" + + @abstractmethod + def forward(self, Z: Tensor) -> Tensor: + pass + + @abstractmethod + def train_step(self, Z: Tensor) -> Tensor: + pass + + @abstractmethod + def encode(self, Z: Tensor, i: int) -> Tensor: + """Calculate the :attr:`i`-th hidden representation + + Parameters + ---------- + Z : Tensor + a tensor of shape ``n x d`` containing the input data to encode, where ``d`` is the + input dimensionality. + i : int + The stop index of slice of the MLP used to encode the input. That is, use all + layers in the MLP _up to_ :attr:`i` (i.e., ``MLP[:i]``). This can be any integer + value, and the behavior of this function is dependent on the underlying list + slicing behavior. For example: + + * ``i=0``: use a 0-layer MLP (i.e., a no-op) + * ``i=1``: use only the first block + * ``i=-1``: use _up to_ the final block + + Returns + ------- + Tensor + a tensor of shape ``n x h`` containing the :attr:`i`-th hidden representation, where + ``h`` is the number of neurons in the :attr:`i`-th hidden layer. + """ + pass + + +PredictorRegistry = ClassRegistry[Predictor]() + + +class _FFNPredictorBase(Predictor, HyperparametersMixin): + """A :class:`_FFNPredictorBase` is the base class for all :class:`Predictor`\s that use an + underlying :class:`SimpleFFN` to map the learned fingerprint to the desired output. + """ + + _T_default_criterion: LossFunction + _T_default_metric: Metric + + def __init__( + self, + n_tasks: int = 1, + input_dim: int = DEFAULT_HIDDEN_DIM, + hidden_dim: int = 300, + n_layers: int = 1, + dropout: float = 0.0, + activation: str = "relu", + criterion: LossFunction | None = None, + task_weights: Tensor | None = None, + threshold: float | None = None, + output_transform: UnscaleTransform | None = None, + ): + super().__init__() + self.save_hyperparameters(ignore=["criterion", "output_transform"]) + self.hparams["cls"] = self.__class__ + + self.ffn = MLP.build( + input_dim, n_tasks * self.n_targets, hidden_dim, n_layers, dropout, activation + ) + task_weights = torch.ones(n_tasks) if task_weights is None else task_weights + self.criterion = criterion or Factory.build( + self._T_default_criterion, task_weights=task_weights, threshold=threshold + ) + self.hparams["criterion"] = self.criterion + self.output_transform = output_transform if output_transform is not None else nn.Identity() + self.hparams["output_transform"] = self.output_transform + + @property + def input_dim(self) -> int: + return self.ffn.input_dim + + @property + def output_dim(self) -> int: + return self.ffn.output_dim + + @property + def n_tasks(self) -> int: + return self.output_dim // self.n_targets + + def forward(self, Z: Tensor) -> Tensor: + return self.output_transform(self.ffn(Z)) + + def encode(self, Z: Tensor, i: int) -> Tensor: + return self.ffn[:i](Z) + + +@PredictorRegistry.register("regression") +class RegressionFFN(_FFNPredictorBase): + n_targets = 1 + _T_default_criterion = MSELoss + _T_default_metric = MSEMetric + + def train_step(self, Z: Tensor) -> Tensor: + return super().forward(Z) + + +@PredictorRegistry.register("regression-mve") +class MveFFN(RegressionFFN): + n_targets = 2 + _T_default_criterion = MVELoss + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + mean, var = torch.chunk(Y, self.n_targets, 1) + + mean = self.scale * mean + self.loc + var = var * self.scale**2 + + return torch.cat((mean, var), 1) + + def train_step(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + mean, var = torch.chunk(Y, self.n_targets, 1) + var = F.softplus(var) + + return torch.cat((mean, var), 1) + + +@PredictorRegistry.register("regression-evidential") +class EvidentialFFN(RegressionFFN): + n_targets = 4 + _T_default_criterion = EvidentialLoss + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + mean, v, alpha, beta = torch.chunk(Y, self.n_targets, 1) + + mean = self.scale * mean + self.loc + v = v * self.scale**2 + + return torch.cat((mean, v, alpha, beta), 1) + + def train_step(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + mean, v, alpha, beta = torch.chunk(Y, self.n_targets, 1) + + v = F.softplus(v) + alpha = F.softplus(alpha) + 1 + beta = F.softplus(beta) + + return torch.cat((mean, v, alpha, beta), 1) + + +class BinaryClassificationFFNBase(_FFNPredictorBase): + pass + + +@PredictorRegistry.register("classification") +class BinaryClassificationFFN(BinaryClassificationFFNBase): + n_targets = 1 + _T_default_criterion = BCELoss + _T_default_metric = BinaryAUROCMetric + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + + return Y.sigmoid() + + def train_step(self, Z: Tensor) -> Tensor: + return super().forward(Z) + + +@PredictorRegistry.register("classification-dirichlet") +class BinaryDirichletFFN(BinaryClassificationFFNBase): + n_targets = 2 + _T_default_criterion = BinaryDirichletLoss + _T_default_metric = BinaryAUROCMetric + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + alpha, beta = torch.chunk(Y, 2, 1) + + return beta / (alpha + beta) + + def train_step(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + + F.softplus(Y) + 1 + + +@PredictorRegistry.register("multiclass") +class MulticlassClassificationFFN(_FFNPredictorBase): + n_targets = 1 + _T_default_criterion = CrossEntropyLoss + _T_default_metric = CrossEntropyMetric + + def __init__( + self, + n_classes: int, + n_tasks: int = 1, + input_dim: int = DEFAULT_HIDDEN_DIM, + hidden_dim: int = 300, + n_layers: int = 1, + dropout: float = 0.0, + activation: str = "relu", + criterion: LossFunction | None = None, + task_weights: Tensor | None = None, + threshold: float | None = None, + output_transform: UnscaleTransform | None = None, + ): + super().__init__( + n_tasks * n_classes, + input_dim, + hidden_dim, + n_layers, + dropout, + activation, + criterion, + task_weights, + threshold, + output_transform, + ) + + self.n_classes = n_classes + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + Y = Y.reshape(Y.shape[0], -1, self.n_classes) + + return Y.softmax(-1) + + def train_step(self, Z: Tensor) -> Tensor: + return super().forward(Z).reshape(Z.shape[0], -1, self.n_classes) + + +@PredictorRegistry.register("multiclass-dirichlet") +class MulticlassDirichletFFN(MulticlassClassificationFFN): + _T_default_criterion = MulticlassDirichletLoss + _T_default_metric = CrossEntropyMetric + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z).reshape(len(Z), -1, self.n_classes) + + Y = Y.softmax(-1) + Y = F.softplus(Y) + 1 + + alpha = Y + Y = Y / Y.sum(-1, keepdim=True) + + return torch.cat((Y, alpha), 1) + + def train_step(self, Z: Tensor) -> Tensor: + Y = super().forward(Z).reshape(len(Z), -1, self.n_classes) + + return F.softplus(Y) + 1 + + +class _Exp(nn.Module): + def forward(self, X: Tensor): + return X.exp() + + +@PredictorRegistry.register("spectral") +class SpectralFFN(_FFNPredictorBase): + n_targets = 1 + _T_default_criterion = SIDLoss + _T_default_metric = SIDMetric + + def __init__(self, *args, spectral_activation: str | None = "softplus", **kwargs): + super().__init__(*args, **kwargs) + + match spectral_activation: + case "exp": + spectral_activation = _Exp() + case "softplus" | None: + spectral_activation = nn.Softplus() + case _: + raise ValueError( + f"Unknown spectral activation: {spectral_activation}. " + "Expected one of 'exp', 'softplus' or None." + ) + + self.ffn.add_module("spectral_activation", spectral_activation) + + def forward(self, Z: Tensor) -> Tensor: + Y = super().forward(Z) + Y = self.ffn.spectral_activation(Y) + return Y / Y.sum(1, keepdim=True) + + train_step = forward diff --git a/chemprop/nn/transforms.py b/chemprop/nn/transforms.py new file mode 100644 index 0000000000000000000000000000000000000000..b602fa8fea550cbdb039aaf296b3d26f0b9bba63 --- /dev/null +++ b/chemprop/nn/transforms.py @@ -0,0 +1,59 @@ +import torch +from numpy.typing import ArrayLike +from sklearn.preprocessing import StandardScaler +from torch import Tensor, nn + +from chemprop.data.collate import BatchMolGraph + + +class _ScaleTransformMixin(nn.Module): + def __init__(self, mean: ArrayLike, scale: ArrayLike, pad: int = 0): + super().__init__() + + mean = torch.cat([torch.zeros(pad), torch.tensor(mean, dtype=torch.float)]) + scale = torch.cat([torch.ones(pad), torch.tensor(scale, dtype=torch.float)]) + + if mean.shape != scale.shape: + raise ValueError( + f"uneven shapes for 'mean' and 'scale'! got: mean={mean.shape}, scale={scale.shape}" + ) + + self.register_buffer("mean", mean.unsqueeze(0)) + self.register_buffer("scale", scale.unsqueeze(0)) + + @classmethod + def from_standard_scaler(cls, scaler: StandardScaler, pad: int = 0): + return cls(scaler.mean_, scaler.scale_, pad=pad) + + +class ScaleTransform(_ScaleTransformMixin): + def forward(self, X: Tensor) -> Tensor: + if self.training: + return X + + return (X - self.mean) / self.scale + + +class UnscaleTransform(_ScaleTransformMixin): + def forward(self, X: Tensor) -> Tensor: + if self.training: + return X + + return X * self.scale + self.mean + + +class GraphTransform(nn.Module): + def __init__(self, V_transform: ScaleTransform, E_transform: ScaleTransform): + super().__init__() + + self.V_transform = V_transform + self.E_transform = E_transform + + def forward(self, bmg: BatchMolGraph) -> BatchMolGraph: + if self.training: + return bmg + + bmg.V = self.V_transform(bmg.V) + bmg.E = self.E_transform(bmg.E) + + return bmg diff --git a/chemprop/nn/utils.py b/chemprop/nn/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..740422d169c8a8e18034b717f32b6929311c3acf --- /dev/null +++ b/chemprop/nn/utils.py @@ -0,0 +1,45 @@ +from enum import auto +from torch import nn + +from chemprop.utils.utils import EnumMapping + + +class Activation(EnumMapping): + RELU = auto() + LEAKYRELU = auto() + PRELU = auto() + TANH = auto() + SELU = auto() + ELU = auto() + + +def get_activation_function(activation: str | Activation) -> nn.Module: + """Gets an activation function module given the name of the activation. + + See :class:`~chemprop.v2.models.utils.Activation` for available activations. + + Parameters + ---------- + activation : str | Activation + The name of the activation function. + + Returns + ------- + nn.Module + The activation function module. + """ + match Activation.get(activation): + case Activation.RELU: + return nn.ReLU() + case Activation.LEAKYRELU: + return nn.LeakyReLU(0.1) + case Activation.PRELU: + return nn.PReLU() + case Activation.TANH: + return nn.Tanh() + case Activation.SELU: + return nn.SELU() + case Activation.ELU: + return nn.ELU() + case _: + raise RuntimeError("unreachable code reached!") diff --git a/chemprop/schedulers.py b/chemprop/schedulers.py new file mode 100644 index 0000000000000000000000000000000000000000..08e20ef46e91652630fcfb2e4b85aa5680c4b0e1 --- /dev/null +++ b/chemprop/schedulers.py @@ -0,0 +1,127 @@ +import numpy as np +from numpy.typing import ArrayLike +from torch.optim import Optimizer +from torch.optim.lr_scheduler import LRScheduler + + +class NoamLR(LRScheduler): + r"""A Noam learning rate scheduler schedules the learning rate with a piecewise linear followed + by an exponential decay. + + The learning rate increases linearly from ``init_lr`` to ``max_lr`` over the course of + the first warmup_steps then decreases exponentially to ``final_lr`` over the course of the + remaining ``total_steps - warmup_steps`` (where ``total_steps = total_epochs * steps_per_epoch``). This is roughly based on the learning rate schedule from [1]_, section 5.3. + + Formally, the learning rate schedule is defined as: + + .. math:: + \mathtt{lr}(i) &= + \begin{cases} + \mathtt{init\_lr} + \delta \cdot i &\text{if } i < \mathtt{warmup\_steps} \\ + \mathtt{max\_lr} \cdot \left( \frac{\mathtt{final\_lr}}{\mathtt{max\_lr}} \right)^{\gamma(i)} &\text{otherwise} \\ + \end{cases} + \\ + \delta &\mathrel{:=} + \frac{\mathtt{max\_lr} - \mathtt{init\_lr}}{\mathtt{warmup\_steps}} \\ + \gamma(i) &\mathrel{:=} + \frac{i - \mathtt{warmup\_steps}}{\mathtt{total\_steps} - \mathtt{warmup\_steps}} + + + Parameters + ----------- + optimizer : Optimizer + A PyTorch optimizer. + warmup_epochs : ArrayLike + The number of epochs during which to linearly increase the learning rate. + total_epochs : int + The total number of epochs. + steps_per_epoch : int + The number of steps (batches) per epoch. + init_lr : ArrayLike + The initial learning rate. + max_lr : ArrayLike + The maximum learning rate (achieved after ``warmup_epochs``). + final_lr : ArrayLike + The final learning rate (achieved after ``total_epochs``). + + References + ---------- + .. [1] Vaswani, A., Shazeer, N., Parmar, N., Uszkoreit, J., Jones, L., Gomez, A.N., Kaiser, Ł. and Polosukhin, I. "Attention is all you need." Advances in neural information processing systems, 2017, 30. https://arxiv.org/abs/1706.03762 + """ + + def __init__( + self, + optimizer: Optimizer, + warmup_epochs: ArrayLike, + total_epochs: int, + steps_per_epoch: int, + init_lrs: ArrayLike, + max_lrs: ArrayLike, + final_lrs: ArrayLike, + ): + self.num_lrs = len(optimizer.param_groups) + warmup_epochs = np.atleast_1d(warmup_epochs) + init_lrs = np.atleast_1d(init_lrs) + max_lrs = np.atleast_1d(max_lrs) + self.final_lrs = np.atleast_1d(final_lrs) + + if not ( + self.num_lrs + == len(warmup_epochs) + == len(init_lrs) + == len(max_lrs) + == len(self.final_lrs) + ): + raise ValueError( + "Number of param groups must match number of: " + "'warmup_epochs', 'init_lr', 'max_lr', 'final_lr'! " + f"got: {len(self.optimizer.param_groups)} param groups, " + f"{len(init_lrs)} init_lr, " + f"{len(max_lrs)} max_lr, " + f"{len(self.final_lrs)} final_lr" + ) + + self.current_step = 0 + self.lrs = init_lrs + + warmup_steps = (warmup_epochs * steps_per_epoch).astype(int) + total_steps = total_epochs * steps_per_epoch + cooldown_steps = total_steps - warmup_steps + + deltas = (max_lrs - init_lrs) / warmup_steps + gammas = (self.final_lrs / max_lrs) ** (1 / cooldown_steps) + + self.scheds = [] + for i in range(self.num_lrs): + warmup = init_lrs[i] + np.arange(warmup_steps[i]) * deltas[i] + cooldown = max_lrs[i] * (gammas[i] ** np.arange(cooldown_steps[i])) + self.scheds.append(np.concatenate((warmup, cooldown))) + self.scheds = np.array(self.scheds) + + super(NoamLR, self).__init__(optimizer) + + def __len__(self) -> int: + """the number of steps in the learning rate schedule""" + return self.scheds.shape[1] + + def get_lr(self) -> np.ndarray: + """Get a list of the current learning rates""" + return self.lrs + + def step(self, step: int | None = None): + """Step the learning rate + + Parameters + ---------- + step : int | None, default=None + What step to set the learning rate to. If ``None``, use ``self.current_step + 1``. + """ + self.current_step = step if step is not None else self.current_step + 1 + + for i in range(self.num_lrs): + if self.current_step < len(self): + self.lrs[i] = self.scheds[i][self.current_step] + else: + self.lrs[i] = self.final_lrs[i] + + self.optimizer.param_groups[i]["lr"] = self.lrs[i] diff --git a/chemprop/types.py b/chemprop/types.py new file mode 100644 index 0000000000000000000000000000000000000000..f3bc5d2aa21f9e74157cb36d6159585f77de6554 --- /dev/null +++ b/chemprop/types.py @@ -0,0 +1,3 @@ +from rdkit.Chem import Mol + +Rxn = tuple[Mol, Mol] diff --git a/chemprop/utils/__init__.py b/chemprop/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0ef5dbaf5571140e6b70c9b3ac7d9d988ddb386f --- /dev/null +++ b/chemprop/utils/__init__.py @@ -0,0 +1,4 @@ +from .registry import ClassRegistry, Factory +from .utils import EnumMapping, make_mol, pretty_shape + +__all__ = ["ClassRegistry", "Factory", "EnumMapping", "make_mol", "pretty_shape"] diff --git a/chemprop/utils/registry.py b/chemprop/utils/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..a4c362d7ee9082bdb9590afecb9e88353971a55f --- /dev/null +++ b/chemprop/utils/registry.py @@ -0,0 +1,46 @@ +import inspect +from typing import Any, Iterable, Type, TypeVar + +T = TypeVar("T") + + +class ClassRegistry(dict[str, Type[T]]): + def register(self, alias: Any | Iterable[Any] | None = None): + def decorator(cls): + if alias is None: + keys = [cls.__name__.lower()] + elif isinstance(alias, str): + keys = [alias] + else: + keys = alias + + cls.alias = keys[0] + for k in keys: + self[k] = cls + + return cls + + return decorator + + __call__ = register + + def __repr__(self) -> str: # pragma: no cover + return f"{self.__class__.__name__}: {super().__repr__()}" + + def __str__(self) -> str: # pragma: no cover + INDENT = 4 + items = [f"{' ' * INDENT}{repr(k)}: {repr(v)}" for k, v in self.items()] + + return "\n".join([f"{self.__class__.__name__} {'{'}", ",\n".join(items), "}"]) + + +class Factory: + @classmethod + def build(cls, clz_T: Type[T], *args, **kwargs) -> T: + if not inspect.isclass(clz_T): + raise TypeError(f"Expected a class type! got: {type(clz_T)}") + + sig = inspect.signature(clz_T) + kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters.keys()} + + return clz_T(*args, **kwargs) diff --git a/chemprop/utils/utils.py b/chemprop/utils/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..4efd503b0ef61e55367beda1b07b3533e0ec3fa7 --- /dev/null +++ b/chemprop/utils/utils.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Iterable, Iterator + +from rdkit import Chem + + +class EnumMapping(StrEnum): + @classmethod + def get(cls, name: str | EnumMapping) -> EnumMapping: + if isinstance(name, cls): + return name + + try: + return cls[name.upper()] + except KeyError: + raise KeyError( + f"Unsupported {cls.__name__} member! got: '{name}'. expected one of: {cls.keys()}" + ) + + @classmethod + def keys(cls) -> Iterator[str]: + return (e.name for e in cls) + + @classmethod + def values(cls) -> Iterator[str]: + return (e.value for e in cls) + + @classmethod + def items(cls) -> Iterator[tuple[str, str]]: + return zip(cls.keys(), cls.values()) + + +def make_mol(smi: str, keep_h: bool, add_h: bool) -> Chem.Mol: + """build an RDKit molecule from a SMILES string. + + Parameters + ---------- + smi : str + a SMILES string. + keep_h : bool + whether to keep hydrogens in the input smiles. This does not add hydrogens, it only keeps them if they are specified + add_h : bool + whether to add hydrogens to the molecule + + Returns + ------- + Chem.Mol + the RDKit molecule. + """ + if keep_h: + mol = Chem.MolFromSmiles(smi, sanitize=False) + Chem.SanitizeMol( + mol, sanitizeOps=Chem.SanitizeFlags.SANITIZE_ALL ^ Chem.SanitizeFlags.SANITIZE_ADJUSTHS + ) + else: + mol = Chem.MolFromSmiles(smi) + + return Chem.AddHs(mol) if add_h else mol + + +def pretty_shape(shape: Iterable[int]) -> str: + """Make a pretty string from an input shape + + Example + -------- + >>> X = np.random.rand(10, 4) + >>> X.shape + (10, 4) + >>> pretty_shape(X.shape) + '10 x 4' + """ + return " x ".join(map(str, shape)) diff --git a/chemprop/utils/v1_to_v2.py b/chemprop/utils/v1_to_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..1b00f13a16f1ed4be53f33131e8daac5f45a8c27 --- /dev/null +++ b/chemprop/utils/v1_to_v2.py @@ -0,0 +1,151 @@ +from os import PathLike + +from lightning.pytorch import __version__ +from lightning.pytorch.utilities.parsing import AttributeDict +import torch + +from chemprop.nn.metrics import MetricRegistry +from chemprop.nn.agg import AggregationRegistry +from chemprop.nn.predictors import PredictorRegistry +from chemprop.nn.loss import LossFunctionRegistry +from chemprop.nn.message_passing import AtomMessagePassing, BondMessagePassing +from chemprop.utils import Factory +from chemprop.nn.transforms import UnscaleTransform + + +def convert_state_dict_v1_to_v2(model_v1_dict: dict) -> dict: + """Converts v1 model dictionary to a v2 state dictionary""" + + state_dict_v2 = {} + args_v1 = model_v1_dict["args"] + + state_dict_v1 = model_v1_dict["state_dict"] + state_dict_v2["message_passing.W_i.weight"] = state_dict_v1["encoder.encoder.0.W_i.weight"] + state_dict_v2["message_passing.W_h.weight"] = state_dict_v1["encoder.encoder.0.W_h.weight"] + state_dict_v2["message_passing.W_o.weight"] = state_dict_v1["encoder.encoder.0.W_o.weight"] + state_dict_v2["message_passing.W_o.bias"] = state_dict_v1["encoder.encoder.0.W_o.bias"] + + for i in range(args_v1.ffn_num_layers): + suffix = 0 if i == 0 else 2 + state_dict_v2[f"predictor.ffn.{i}.{suffix}.weight"] = state_dict_v1[ + f"readout.{i*3+1}.weight" + ] + state_dict_v2[f"predictor.ffn.{i}.{suffix}.bias"] = state_dict_v1[f"readout.{i*3+1}.bias"] + + if args_v1.dataset_type == "regression": + state_dict_v2["predictor.output_transform.mean"] = torch.tensor( + model_v1_dict["data_scaler"]["means"], dtype=torch.float32 + ).unsqueeze(0) + state_dict_v2["predictor.output_transform.scale"] = torch.tensor( + model_v1_dict["data_scaler"]["stds"], dtype=torch.float32 + ).unsqueeze(0) + + if args_v1.target_weights is not None: + task_weights = torch.tensor(args_v1.target_weights).unsqueeze(0) + else: + task_weights = torch.ones(args_v1.num_tasks).unsqueeze(0) + + state_dict_v2["predictor.criterion.task_weights"] = task_weights + + return state_dict_v2 + + +def convert_hyper_parameters_v1_to_v2(model_v1_dict: dict) -> dict: + """Converts v1 model dictionary to v2 hyper_parameters dictionary""" + hyper_parameters_v2 = {} + + args_v1 = model_v1_dict["args"] + hyper_parameters_v2["batch_norm"] = False + hyper_parameters_v2["metrics"] = [Factory.build(MetricRegistry[args_v1.metric])] + hyper_parameters_v2["warmup_epochs"] = args_v1.warmup_epochs + hyper_parameters_v2["init_lr"] = args_v1.init_lr + hyper_parameters_v2["max_lr"] = args_v1.max_lr + hyper_parameters_v2["final_lr"] = args_v1.final_lr + + # convert the message passing block + W_i_shape = model_v1_dict["state_dict"]["encoder.encoder.0.W_i.weight"].shape + W_h_shape = model_v1_dict["state_dict"]["encoder.encoder.0.W_h.weight"].shape + W_o_shape = model_v1_dict["state_dict"]["encoder.encoder.0.W_o.weight"].shape + + d_h = W_i_shape[0] + d_v = W_o_shape[1] - d_h + d_e = W_h_shape[1] - d_h if args_v1.atom_messages else W_i_shape[1] - d_v + + hyper_parameters_v2["message_passing"] = AttributeDict( + { + "activation": args_v1.activation, + "bias": args_v1.bias, + "cls": BondMessagePassing if not args_v1.atom_messages else AtomMessagePassing, + "d_e": d_e, # the feature dimension of the edges + "d_h": args_v1.hidden_size, # dimension of the hidden layer + "d_v": d_v, # the feature dimension of the vertices + "d_vd": None, # ``d_vd`` is the number of additional features that will be concatenated to atom-level features *after* message passing + "depth": args_v1.depth, + "dropout": args_v1.dropout, + "undirected": args_v1.undirected, + } + ) + + # convert the aggregation block + hyper_parameters_v2["agg"] = { + "dim": 0, # in v1, the aggregation is always done on the atom features + "cls": AggregationRegistry[args_v1.aggregation], + } + if args_v1.aggregation == "norm": + hyper_parameters_v2["agg"]["norm"] = args_v1.aggregation_norm + + # convert the predictor block + if args_v1.target_weights is not None: + task_weights = torch.tensor(args_v1.target_weights).unsqueeze(0) + else: + task_weights = torch.ones(args_v1.num_tasks).unsqueeze(0) + + hyper_parameters_v2["predictor"] = AttributeDict( + { + "activation": args_v1.activation, + "cls": PredictorRegistry[args_v1.dataset_type], + "criterion": Factory.build( + LossFunctionRegistry[args_v1.loss_function], task_weights=task_weights + ), + "task_weights": None, + "dropout": args_v1.dropout, + "hidden_dim": args_v1.ffn_hidden_size, + "input_dim": args_v1.hidden_size, + "n_layers": args_v1.ffn_num_layers - 1, + "n_tasks": args_v1.num_tasks, + } + ) + + if args_v1.dataset_type == "regression": + hyper_parameters_v2["predictor"]["output_transform"] = UnscaleTransform( + model_v1_dict["data_scaler"]["means"], model_v1_dict["data_scaler"]["stds"] + ) + + return hyper_parameters_v2 + + +def convert_model_dict_v1_to_v2(model_v1_dict: dict) -> dict: + """Converts a v1 model dictionary from a loaded .pt file to a v2 model dictionary""" + + model_v2_dict = {} + + model_v2_dict["epoch"] = None + model_v2_dict["global_step"] = None + model_v2_dict["pytorch-lightning_version"] = __version__ + model_v2_dict["state_dict"] = convert_state_dict_v1_to_v2(model_v1_dict) + model_v2_dict["loops"] = None + model_v2_dict["callbacks"] = None + model_v2_dict["optimizer_states"] = None + model_v2_dict["lr_schedulers"] = None + model_v2_dict["hparams_name"] = "kwargs" + model_v2_dict["hyper_parameters"] = convert_hyper_parameters_v1_to_v2(model_v1_dict) + + return model_v2_dict + + +def convert_model_file_v1_to_v2(model_v1_file: PathLike, model_v2_file: PathLike) -> None: + """Converts a v1 model .pt file to a v2 model .ckpt file""" + + model_v1_dict = torch.load(model_v1_file) + model_v2_dict = convert_model_dict_v1_to_v2(model_v1_dict) + torch.save(model_v2_dict, model_v2_file) diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000000000000000000000000000000000000..1de279d45e23e682e3e418143f200b553d3e4b64 --- /dev/null +++ b/environment.yml @@ -0,0 +1,16 @@ +name: chemprop +channels: + - pytorch + - conda-forge +dependencies: + - python>=3.11 + - pytorch::pytorch>=2.1 + - astartes + - aimsim + - configargparse + - lightning>=2.0 + - numpy<2.0 + - pandas + - rdkit + - scikit-learn + - scipy diff --git a/jupyter_examples/convert_v1_to_v2.ipynb b/jupyter_examples/convert_v1_to_v2.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..6ab05cf12839a70451a8775a0d1b1b9ff67a19aa --- /dev/null +++ b/jupyter_examples/convert_v1_to_v2.ipynb @@ -0,0 +1,465 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Convert v1 to v2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "from pprint import pprint\n", + "from pathlib import Path\n", + "\n", + "from chemprop.utils.v1_to_v2 import convert_model_dict_v1_to_v2\n", + "from chemprop.models.model import MPNN\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change model paths here" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "model_v1_input_path = chemprop_dir / \"tests/data/example_model_v1_regression_mol.pt\" # path to v1 model .pt file\n", + "model_v2_output_path = Path.cwd() / \"converted_model.ckpt\" # path to save the converted model .ckpt file" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Load v1 model .pt file" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "model_v1_dict = torch.load(model_v1_input_path)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['args',\n", + " 'state_dict',\n", + " 'data_scaler',\n", + " 'features_scaler',\n", + " 'atom_descriptor_scaler',\n", + " 'bond_descriptor_scaler',\n", + " 'atom_bond_scaler']\n" + ] + } + ], + "source": [ + "# Here are all the keys that is stored in v1 model\n", + "pprint(list(model_v1_dict.keys()))" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'activation': 'ReLU',\n", + " 'adding_bond_types': True,\n", + " 'adding_h': False,\n", + " 'aggregation': 'mean',\n", + " 'aggregation_norm': 100,\n", + " 'atom_constraints': [],\n", + " 'atom_descriptor_scaling': True,\n", + " 'atom_descriptors': None,\n", + " 'atom_descriptors_path': None,\n", + " 'atom_descriptors_size': 0,\n", + " 'atom_features_size': 0,\n", + " 'atom_messages': False,\n", + " 'atom_targets': [],\n", + " 'batch_size': 50,\n", + " 'bias': False,\n", + " 'bias_solvent': False,\n", + " 'bond_constraints': [],\n", + " 'bond_descriptor_scaling': True,\n", + " 'bond_descriptors': None,\n", + " 'bond_descriptors_path': None,\n", + " 'bond_descriptors_size': 0,\n", + " 'bond_features_size': 0,\n", + " 'bond_targets': [],\n", + " 'cache_cutoff': 10000,\n", + " 'checkpoint_dir': None,\n", + " 'checkpoint_frzn': None,\n", + " 'checkpoint_path': None,\n", + " 'checkpoint_paths': None,\n", + " 'class_balance': False,\n", + " 'config_path': None,\n", + " 'constraints_path': None,\n", + " 'crossval_index_dir': None,\n", + " 'crossval_index_file': None,\n", + " 'crossval_index_sets': None,\n", + " 'cuda': False,\n", + " 'data_path': '/Users/hwpang/Software/chemprop/tests/data/regression.csv',\n", + " 'data_weights_path': None,\n", + " 'dataset_type': 'regression',\n", + " 'depth': 3,\n", + " 'depth_solvent': 3,\n", + " 'device': device(type='cpu'),\n", + " 'dropout': 0.0,\n", + " 'empty_cache': False,\n", + " 'ensemble_size': 1,\n", + " 'epochs': 1,\n", + " 'evidential_regularization': 0,\n", + " 'explicit_h': False,\n", + " 'extra_metrics': [],\n", + " 'features_generator': None,\n", + " 'features_only': False,\n", + " 'features_path': None,\n", + " 'features_scaling': True,\n", + " 'features_size': None,\n", + " 'ffn_hidden_size': 300,\n", + " 'ffn_num_layers': 2,\n", + " 'final_lr': 0.0001,\n", + " 'folds_file': None,\n", + " 'freeze_first_only': False,\n", + " 'frzn_ffn_layers': 0,\n", + " 'gpu': None,\n", + " 'grad_clip': None,\n", + " 'hidden_size': 300,\n", + " 'hidden_size_solvent': 300,\n", + " 'ignore_columns': None,\n", + " 'init_lr': 0.0001,\n", + " 'is_atom_bond_targets': False,\n", + " 'keeping_atom_map': False,\n", + " 'log_frequency': 10,\n", + " 'loss_function': 'mse',\n", + " 'max_data_size': None,\n", + " 'max_lr': 0.001,\n", + " 'metric': 'rmse',\n", + " 'metrics': ['rmse'],\n", + " 'minimize_score': True,\n", + " 'mpn_shared': False,\n", + " 'multiclass_num_classes': 3,\n", + " 'no_adding_bond_types': False,\n", + " 'no_atom_descriptor_scaling': False,\n", + " 'no_bond_descriptor_scaling': False,\n", + " 'no_cache_mol': False,\n", + " 'no_cuda': False,\n", + " 'no_features_scaling': False,\n", + " 'no_shared_atom_bond_ffn': False,\n", + " 'num_folds': 1,\n", + " 'num_lrs': 1,\n", + " 'num_tasks': 1,\n", + " 'num_workers': 8,\n", + " 'number_of_molecules': 1,\n", + " 'overwrite_default_atom_features': False,\n", + " 'overwrite_default_bond_features': False,\n", + " 'phase_features_path': None,\n", + " 'pytorch_seed': 0,\n", + " 'quiet': False,\n", + " 'reaction': False,\n", + " 'reaction_mode': 'reac_diff',\n", + " 'reaction_solvent': False,\n", + " 'resume_experiment': False,\n", + " 'save_dir': '/Users/hwpang/Software/test_chemprop_v1_to_v2/fold_0',\n", + " 'save_preds': False,\n", + " 'save_smiles_splits': True,\n", + " 'seed': 0,\n", + " 'separate_test_atom_descriptors_path': None,\n", + " 'separate_test_bond_descriptors_path': None,\n", + " 'separate_test_constraints_path': None,\n", + " 'separate_test_features_path': None,\n", + " 'separate_test_path': None,\n", + " 'separate_test_phase_features_path': None,\n", + " 'separate_val_atom_descriptors_path': None,\n", + " 'separate_val_bond_descriptors_path': None,\n", + " 'separate_val_constraints_path': None,\n", + " 'separate_val_features_path': None,\n", + " 'separate_val_path': None,\n", + " 'separate_val_phase_features_path': None,\n", + " 'shared_atom_bond_ffn': True,\n", + " 'show_individual_scores': False,\n", + " 'smiles_columns': ['smiles'],\n", + " 'spectra_activation': 'exp',\n", + " 'spectra_phase_mask': None,\n", + " 'spectra_phase_mask_path': None,\n", + " 'spectra_target_floor': 1e-08,\n", + " 'split_key_molecule': 0,\n", + " 'split_sizes': [0.8, 0.1, 0.1],\n", + " 'split_type': 'random',\n", + " 'target_columns': None,\n", + " 'target_weights': None,\n", + " 'task_names': ['logSolubility'],\n", + " 'test': False,\n", + " 'test_fold_index': None,\n", + " 'train_data_size': 400,\n", + " 'undirected': False,\n", + " 'use_input_features': False,\n", + " 'val_fold_index': None,\n", + " 'warmup_epochs': 2.0,\n", + " 'weights_ffn_num_layers': 2}\n" + ] + } + ], + "source": [ + "# Here are the input arguments that is stored in v1 model\n", + "pprint(model_v1_dict['args'].__dict__)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['encoder.encoder.0.cached_zero_vector',\n", + " 'encoder.encoder.0.W_i.weight',\n", + " 'encoder.encoder.0.W_h.weight',\n", + " 'encoder.encoder.0.W_o.weight',\n", + " 'encoder.encoder.0.W_o.bias',\n", + " 'readout.1.weight',\n", + " 'readout.1.bias',\n", + " 'readout.4.weight',\n", + " 'readout.4.bias']\n" + ] + } + ], + "source": [ + "# Here are the state_dict that is stored in v1 model\n", + "pprint(list(model_v1_dict['state_dict'].keys()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Convert loaded v1 model dictionary into v2 model dictionary" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "model_v2_dict = convert_model_dict_v1_to_v2(model_v1_dict)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['epoch',\n", + " 'global_step',\n", + " 'pytorch-lightning_version',\n", + " 'state_dict',\n", + " 'loops',\n", + " 'callbacks',\n", + " 'optimizer_states',\n", + " 'lr_schedulers',\n", + " 'hparams_name',\n", + " 'hyper_parameters']\n" + ] + } + ], + "source": [ + "# Here are all the keys in the converted model\n", + "pprint(list(model_v2_dict.keys()))" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['w_t',\n", + " 'message_passing.W_i.weight',\n", + " 'message_passing.W_h.weight',\n", + " 'message_passing.W_o.weight',\n", + " 'message_passing.W_o.bias',\n", + " 'predictor.loc',\n", + " 'predictor.scale',\n", + " 'predictor.ffn.0.weight',\n", + " 'predictor.ffn.0.bias',\n", + " 'predictor.ffn.3.weight',\n", + " 'predictor.ffn.3.bias']\n" + ] + } + ], + "source": [ + "# Here are all the keys in the converted state_dict\n", + "pprint(list(model_v2_dict['state_dict'].keys()))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['batch_norm',\n", + " 'metrics',\n", + " 'w_t',\n", + " 'warmup_epochs',\n", + " 'init_lr',\n", + " 'max_lr',\n", + " 'final_lr',\n", + " 'message_passing',\n", + " 'agg',\n", + " 'predictor']\n" + ] + } + ], + "source": [ + "# Here are all the keys in the converted hyper_parameters\n", + "pprint(list(model_v2_dict['hyper_parameters'].keys()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Save" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "torch.save(model_v2_dict, model_v2_output_path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Load converted model" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "mpnn = MPNN.load_from_checkpoint(model_v2_output_path)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MPNN(\n", + " (message_passing): BondMessagePassing(\n", + " (W_i): Linear(in_features=147, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=433, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): Identity()\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Linear(in_features=300, out_features=300, bias=True)\n", + " (1): ReLU()\n", + " (2): Dropout(p=0.0, inplace=False)\n", + " (3): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + ")" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# now visually check the converted model is what is expected\n", + "mpnn" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/extra_features_from_featurizer.ipynb b/jupyter_examples/extra_features_from_featurizer.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..06c6c5c5de9701dd61b098be7ce426135030fa5e --- /dev/null +++ b/jupyter_examples/extra_features_from_featurizer.ipynb @@ -0,0 +1,307 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Extra Datapoint Descriptors from Molecule Featurizers\n", + "Datapoints can have extra descriptors concatenated to the learned representation before sending to the FFN. These descriptors can be automatically generated using molecule featurizers." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Loading packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "from rdkit import Chem\n", + "from pathlib import Path\n", + "from chemprop import data, utils\n", + "from rdkit.Chem import rdFingerprintGenerator\n", + "from dataclasses import dataclass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change data inputs here" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "test_path = chemprop_dir / \"tests/data/regression.csv\"\n", + "target_columns = ['logSolubility']" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smileslogSolubility
0OCC3OC(OCC2OC(OC(C#N)c1ccccc1)C(O)C(O)C2O)C(O)...-0.770
1Cc1occc1C(=O)Nc2ccccc2-3.300
2CC(C)=CCCC(C)=CC(=O)-2.060
3c1ccc2c(c1)ccc3c2ccc4c5ccccc5ccc43-7.870
4c1ccsc1-1.330
.........
495Nc1cc(nc(N)n1=O)N2CCCCC2-1.989
496Nc2cccc3nc1ccccc1cc23-4.220
497c1ccc2cc3c4cccc5cccc(c3cc2c1)c45-8.490
498OC(c1ccc(Cl)cc1)(c2ccc(Cl)cc2)C(Cl)(Cl)Cl-5.666
499C1Cc2cccc3cccc1c23-4.630
\n", + "

500 rows × 2 columns

\n", + "
" + ], + "text/plain": [ + " smiles logSolubility\n", + "0 OCC3OC(OCC2OC(OC(C#N)c1ccccc1)C(O)C(O)C2O)C(O)... -0.770\n", + "1 Cc1occc1C(=O)Nc2ccccc2 -3.300\n", + "2 CC(C)=CCCC(C)=CC(=O) -2.060\n", + "3 c1ccc2c(c1)ccc3c2ccc4c5ccccc5ccc43 -7.870\n", + "4 c1ccsc1 -1.330\n", + ".. ... ...\n", + "495 Nc1cc(nc(N)n1=O)N2CCCCC2 -1.989\n", + "496 Nc2cccc3nc1ccccc1cc23 -4.220\n", + "497 c1ccc2cc3c4cccc5cccc(c3cc2c1)c45 -8.490\n", + "498 OC(c1ccc(Cl)cc1)(c2ccc(Cl)cc2)C(Cl)(Cl)Cl -5.666\n", + "499 C1Cc2cccc3cccc1c23 -4.630\n", + "\n", + "[500 rows x 2 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_test = pd.read_csv(test_path)\n", + "df_test" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "smis = df_test['smiles']\n", + "ys = df_test.loc[:, target_columns].values" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Creating custom featurizers\n", + "Custom featurizers can be made by inheriting the ```SimpleMoleculeMolGraphFeaturizer``` class.\n", + "These featurizers must override the following methods:\n", + "- ```__len__(self)```\n", + "- ```__call__(self, mol: Chem.mol)```\n", + "\n", + "Note that this is just an example of how to create a custom featurizer. The `MorganBinaryFeaturizer` in `featurizers/molecule.py` already implements this functionality." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "@dataclass\n", + "class MorganFingerprintMoleculeFeaturizer:\n", + " fp_size: int = 2048\n", + "\n", + " def __post_init__(self):\n", + " self.mfpgen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=self.fp_size)\n", + "\n", + " def __len__(self) -> int:\n", + " \"\"\"the length of the feature vector\"\"\"\n", + " return self.fp_size\n", + "\n", + " def __call__(self, mol: Chem.Mol) -> np.ndarray:\n", + " \"\"\"Featurize the molecule ``mol``\"\"\"\n", + " fp = self.mfpgen.GetFingerprintAsNumPy(mol)\n", + " return fp" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Testing the featurizer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "((2048,), array([0, 1, 0, ..., 0, 0, 0], dtype=uint8))" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mf = MorganFingerprintMoleculeFeaturizer()\n", + "morgan = mf(utils.make_mol(smis[0], keep_h=False, add_h=False))\n", + "morgan.shape, morgan" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading featurizers into datapoints" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[MoleculeDatapoint(mol=, y=array([-0.77]), weight=1.0, gt_mask=None, lt_mask=None, x_d=array([0, 1, 0, ..., 0, 0, 0], dtype=uint8), x_phase=None, name='OCC3OC(OCC2OC(OC(C#N)c1ccccc1)C(O)C(O)C2O)C(O)C(O)C3O', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=array([-3.3]), weight=1.0, gt_mask=None, lt_mask=None, x_d=array([0, 0, 0, ..., 0, 0, 0], dtype=uint8), x_phase=None, name='Cc1occc1C(=O)Nc2ccccc2', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=array([-2.06]), weight=1.0, gt_mask=None, lt_mask=None, x_d=array([0, 0, 0, ..., 0, 0, 0], dtype=uint8), x_phase=None, name='CC(C)=CCCC(C)=CC(=O)', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=array([-7.87]), weight=1.0, gt_mask=None, lt_mask=None, x_d=array([0, 0, 0, ..., 0, 0, 0], dtype=uint8), x_phase=None, name='c1ccc2c(c1)ccc3c2ccc4c5ccccc5ccc43', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=array([-1.33]), weight=1.0, gt_mask=None, lt_mask=None, x_d=array([0, 0, 0, ..., 0, 0, 0], dtype=uint8), x_phase=None, name='c1ccsc1', V_f=None, E_f=None, V_d=None)]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mfs = [MorganFingerprintMoleculeFeaturizer()] # supply a list of all featurizers that \n", + " # will generate the extra descriptors. This is separate \n", + " # from the main featurizer supplied to molecule datasets.\n", + " \n", + "# An arbitrary amount of molecule featurizers can be supplied to each datapoint in a dataset.\n", + "# Note that pre-obtained extra descriptors cannot also be added at the same time, as shown in\n", + "# the loaded molecule features notebook. An attempt to do so will result in an error.\n", + " \n", + "all_data = [data.MoleculeDatapoint.from_smi(smi, y=y, mfs=mfs) for smi, y in zip(smis, ys)]\n", + "all_data[:5]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/mpnn_fingerprints.ipynb b/jupyter_examples/mpnn_fingerprints.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..93f7dd0525f659198869bf12063cd69d5b1f35f5 --- /dev/null +++ b/jupyter_examples/mpnn_fingerprints.ipynb @@ -0,0 +1,361 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Encoding fingerprint latent representation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import torch\n", + "from sklearn.decomposition import PCA\n", + "from pathlib import Path\n", + "\n", + "from chemprop import data, featurizers, models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change model input here" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "checkpoint_path = chemprop_dir / \"tests/data/example_model_v2_regression_mol.ckpt\" # path to the checkpoint file.\n", + "# If the checkpoint file is generated using the training notebook,\n", + "# it will be in the `checkpoints` folder with name similar to `checkpoints/epoch=19-step=180.ckpt`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load model" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MPNN(\n", + " (message_passing): BondMessagePassing(\n", + " (W_i): Linear(in_features=86, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=372, out_features=300, bias=True)\n", + " (W_d): Linear(in_features=300, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(300, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=300, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " )\n", + ")" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mpnn = models.MPNN.load_from_checkpoint(checkpoint_path)\n", + "mpnn.eval()\n", + "mpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change data input here" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "test_path = '../tests/data/smis.csv'\n", + "smiles_column = 'smiles'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load data" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[MoleculeDatapoint(mol=, y=None, weight=1.0, gt_mask=None, lt_mask=None, x_d=None, x_phase=None, name='Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc14', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=None, weight=1.0, gt_mask=None, lt_mask=None, x_d=None, x_phase=None, name='COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)CCc3ccccc23', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=None, weight=1.0, gt_mask=None, lt_mask=None, x_d=None, x_phase=None, name='COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=None, weight=1.0, gt_mask=None, lt_mask=None, x_d=None, x_phase=None, name='OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(Cl)sc4[nH]3', V_f=None, E_f=None, V_d=None),\n", + " MoleculeDatapoint(mol=, y=None, weight=1.0, gt_mask=None, lt_mask=None, x_d=None, x_phase=None, name='Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)NCC#N)c1', V_f=None, E_f=None, V_d=None)]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_test = pd.read_csv(test_path)\n", + "\n", + "smis = df_test[smiles_column]\n", + "\n", + "test_data = [data.MoleculeDatapoint.from_smi(smi) for smi in smis]\n", + "test_data[:5]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get featurizer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get datasets" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "test_dset = data.MoleculeDataset(test_data, featurizer=featurizer)\n", + "test_loader = data.build_dataloader(test_dset, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Calculate fingerprints" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`models.MPNN.encoding(inputs : BatchMolGraph, i : int)` calculate the i-th hidden representation.\n", + "\n", + "`i` ia the stop index of slice of the MLP used to encode the input. That is, use all\n", + "layers in the MLP _up to_ :attr:`i` (i.e., ``MLP[:i]``). This can be any integer\n", + "value, and the behavior of this function is dependent on the underlying list\n", + "slicing behavior. For example:\n", + "\n", + "* ``i=0``: use a 0-layer MLP (i.e., a no-op)\n", + "* ``i=1``: use only the first block\n", + "* ``i=-1``: use _up to_ the second-to-last block" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "torch.Size([100, 300])" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with torch.no_grad():\n", + " fingerprints = [\n", + " mpnn.encoding(batch.bmg, batch.V_d, batch.X_d, i=0)\n", + " for batch in test_loader\n", + " ]\n", + " fingerprints = torch.cat(fingerprints, 0)\n", + "\n", + "fingerprints.shape" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "torch.Size([100, 300])" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "with torch.no_grad():\n", + " encodings = [\n", + " mpnn.encoding(batch.bmg, batch.V_d, batch.X_d, i=1)\n", + " for batch in test_loader\n", + " ]\n", + " encodings = torch.cat(encodings, 0)\n", + "\n", + "encodings.shape" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Using fingerprints" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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rr/+ev3Hf/yH3n33Xa1bt05z585tC70eD4t5YE9WvZDbJTDAHGbsO8rrn53Q8+JBCRGARDMVfrdv367CwsK2r0eNGqU333xTb731lq644goFg8GEDxBIFKteyO0QGGCenT7it4ui4bnK92arsykNj1pnYIuG5ybl5/OGBEAymAq/Pp9PW7dujTg2ZMgQvfHGG3rnnXc0Z86cRI4NSCirXshTHRgQH2bsO8rM8KhsWoEkdXg+h78um1aQtPZ9vCEBkAymwu+kSZP09NNPdzg+ePBgrVu3TvX19QkbGJBoVr2QpzowID7M2EdXUpivFbPGyeeN/L193uyk10DzhgRAMpha8HbLLbdo8+bNUb83ZMgQ/fd//7def/31hAwMSIbwC3n7xTO+BC+esernIHHCM/aN/paoH7N71Pr4uXHGvqQwX1MKfJZv2MIbEgDJYCr8fu1rX9PXvva1Tr/v8/k0cCCr12FvVr2QpyowID7hGfvSihp5pIgAzIx96/WxujsJb0gAJIPHSEBvsi1btuiJJ57QypUr9cUXX+jQoUOJGJstBAIBeb1e+f1+5eTkpHo4AJKMtlr2Eu72IEV/Q+K29nMAojOT1+IOv/v379dzzz2nxx57TH/+85919tln69JLL9Ull1yiQYMGxTVwOyL8Au4TDBnM2NsIb0gAdCep4fedd97RY489plWrVmnkyJG6/PLLdcMNN+h///d/VVBQ0KOB2xHhFwBSjzckALpiJq+Zqvk97bTTFAgEdNlll+mtt97SKaecIklavHhx/KMFAKAbqag5BpCeTLU6+/DDD/Wtb31L//RP/5SWs7wAAABIb6bC7yeffKLRo0ertLRUxx9/vK6//nq99957bGkMAAAARzAVfocMGaKbbrpJW7Zs0W9/+1s1NjbqzDPP1OHDh7Vy5Up99NFHyRonAAAA0GOmwu+RJk2apIqKCjU0NOihhx7SunXrdNJJJ+m0005L5PgAAACAhIk7/IZ5vV4tWLBA7777rmpqanTuuecmYFgAAABA4pkKv/v379fLL7+s5ubmDt8LBALatm2b7r777oQNDgAAAEgkU+H3kUce0f3336/+/ft3+F5OTo4eeOABPfbYYwkbHADEKhgyVLV1t17atF1VW3crGOrx5pUAgDRkqs/vU089pVtuuaXT71977bW67bbbdPXVV/d4YAAQq3TcAYxN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mvTII49E/ZkrV67Utddeqz179iTr1wIASzDzCwBp4KijjtLBgwf11FNPafTo0R2CryR5PJ624CtJb7zxhvbt26fJkydr1qxZWrVqlfbu3WvlsAHAcoRfAHAwwzC0Zs0a/fGPf9SkSZP08ccfa/To0THd9vHHH9ell16qzMxMFRYWasSIEXruueeSPGIASC3CLwA40CuvvKJ+/fopOztb559/vmbMmKElS5Yo1kq2PXv26Pnnn9esWbPajs2aNUuPP/54soYMALbQK9UDAACY90//9E9asWKF+vTpo8GDB6tXr9Y/51//+te1efPmbm//9NNPq6WlRRMnTmw7ZhiGQqGQPvroI339619P2tgBIJWY+QUAB+rbt69GjRqlE044oS34StJll12mjz76SC+99FKH2xiGIb/fL6m15OG6667Tpk2b2v795S9/0dlnn60nnnjCst8DAKxG+AWANDJ9+nTNmDFDM2fO1J133ql3331Xn376qV555RVNnjy5rfVZTU2NfvjDH6qwsDDi38yZM/Xkk0+29Qzetm2bNm3apG3btikYDLYF5X/84x8p/k0BID60OgMAh4nW6uxIoVBIjzzyiJ544gm9//776tWrl0488URdeeWVmjdvnn76059q3bp1ev/99zvctrGxUUOGDNELL7yg73znO5ozZ46efPLJDue98cYbOvfccxP8mwFA8hF+AQAA4BqUPQAAAMA1CL8AAABwDcIvAAAAXIPwCwAAANcg/AIAAMA1CL8AAABwDcIvAAAAXIPwCwAAANcg/AIAAMA1CL8AAABwDcIvAAAAXOP/A2JKaVZSGbVQAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fingerprints = fingerprints.detach()\n", + "\n", + "pca = PCA(n_components=2)\n", + "\n", + "principalComponents = pca.fit_transform(fingerprints)\n", + "\n", + "fig = plt.figure(figsize=(8, 8))\n", + "ax = fig.add_subplot(1, 1, 1)\n", + "ax.set_title(\"Fingerprints\")\n", + "ax.set_xlabel('PCA1'); ax.set_ylabel('PCA2')\n", + "\n", + "ax.scatter(principalComponents[:, 0], principalComponents[:, 1])\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAs0AAAK9CAYAAADfbVFAAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAABXw0lEQVR4nO3de3xU9Z3/8fckkASVDHKdgCg3BUK8ASZGoSoGiVpWdt2KCFWpP2zxiuhPRauRekEttepqseAFu4hYd6sYa7Mil+6qgShp1sYISoSCkAAmZYLQcJk5vz/ym5HJ7cx9zpl5PR+PPNqcnJl8J2PC+3zP5/v5OgzDMAQAAACgQ2mJHgAAAABgdYRmAAAAwAShGQAAADBBaAYAAABMEJoBAAAAE4RmAAAAwAShGQAAADBBaAYAAABMEJoBAAAAE4RmAEBQli5dKofDoW3btvmPXXjhhbrwwgsTNiYAiBdCMwAkgC+AdvSxfv36RA8RAHCMLokeAACksl/84hcaPHhwm+PDhg1LwGhC9/777yd6CAAQF4RmAEigSy+9VGPHjk30MMKWkZGR6CEAQFxQngEAFrVt2zY5HA4tXLhQixcv1tChQ5WZmalzzjlHn3zySZvzN23apKuuukp9+vRRt27dNHz4cN1///0B5/zlL3/RpZdequzsbJ1wwgm6+OKL2y0F+fzzzzVhwgR169ZNJ510kh555BF5vd4257WuaV63bp0cDod+//vf69FHH9VJJ52krKwsXXzxxdqyZUubxz///PMaMmSIunXrpvz8fP3P//xPu3XS//Zv/6ZRo0bpuOOO04knnqixY8dq+fLlQf4kASByzDQDQAK53W59++23AcccDod69erl/3z58uXav3+/fvrTn8rhcOjJJ5/Uv/zLv+jrr79W165dJUmfffaZxo8fr65du+rGG2/UoEGDVFtbq9LSUj366KOSWoLw+PHjlZ2drbvvvltdu3bVb3/7W1144YX685//rIKCAklSfX29LrroIh09elT33nuvjj/+eC1evFjdunUL+nU9/vjjSktL01133SW3260nn3xS06dP14YNG/znLFq0SLfccovGjx+vO+64Q9u2bdOUKVN04okn6qSTTvKft2TJEt12223613/9V91+++1qbm7WZ599pg0bNuiaa64J/YcOAOEwAABx98orrxiS2v3IzMw0DMMwtm7dakgyevXqZTQ2Nvofu3LlSkOSUVpa6j/2gx/8wOjevbvxt7/9LeD7eL1e//+fMmWKkZGRYdTW1vqP7dq1y+jevbvxgx/8wH9szpw5hiRjw4YN/mN79uwxnE6nIcnYunWr//gFF1xgXHDBBf7P165da0gyRo4caRw6dMh//JlnnjEkGX/9618NwzCMQ4cOGb169TLOOecc48iRI/7zli5dakgKeM4rrrjCGDVqlOnPFABiifIMAEig559/XqtWrQr4+NOf/hRwztSpU3XiiSf6Px8/frwk6euvv5Yk7d27V//93/+tn/zkJzr55JMDHutwOCRJHo9H77//vqZMmaIhQ4b4v56Tk6NrrrlGH374oZqamiRJ7733ns4991zl5+f7z+vTp4+mT58e9OuaOXNmQL1z6zF/+umnamho0KxZs9Sly/c3PadPnx7wWiWpR48e+uabb9otSQGAeKE8AwASKD8/33QhYOsg7AuVf//73yV9H0Tz8vI6fI69e/fq4MGDGj58eJuvjRw5Ul6vVzt27NCoUaP0t7/9zV+qcaz2HhvumP/2t79JatslpEuXLho0aFDAsXvuuUcffPCB8vPzNWzYMF1yySW65pprdP755wc9HgCIFDPNAGBx6enp7R43DCPOIwleNMc8cuRIbd68WStWrNC4ceP0n//5nxo3bpxKSkoiHSYABI3QDAA25yu3qK6u7vCcPn366LjjjtPmzZvbfG3Tpk1KS0vTwIEDJUmnnHKKvvrqqzbntffYcJ1yyimS1KajxtGjRwN2HPQ5/vjjNXXqVL3yyivavn27Lr/8cj366KNqbm6O2pgAoDOEZgCwuT59+ugHP/iBXn75ZW3fvj3ga76Z3fT0dF1yySVauXJlQCjdvXu3li9frnHjxik7O1uSdNlll2n9+vWqqKjwn7d371699tprURvz2LFj1atXLy1ZskRHjx71H3/ttdf8JRw+DQ0NAZ9nZGQoNzdXhmHoyJEjURsTAHSGmmYASKA//elP2rRpU5vj5513ntLSgp/XePbZZzVu3DiNHj1aN954owYPHqxt27bpj3/8o6qqqiRJjzzyiFatWqVx48bppptuUpcuXfTb3/5Whw4d0pNPPul/rrvvvlv//u//ruLiYt1+++3+lnOnnHKKPvvss4hfs9QSfB966CHdeuutmjBhgq666ipt27ZNS5cu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avcx9fX17Z4f7H8TSKxw3vPW7rnnHvXv37/NxROsKZz3/MMPP9RLL72kqqqqOIwQ0RbOe/71119rzZo1mj59ut577z1t2bJFN910k44cOaKSkpKgvi8zzUg69957rxwOR6cfwf7j2ZmmpiZdfvnlys3N1UMPPRT5wAEk3OOPP64VK1borbfeUlZWVqKHgxjYv3+/fvzjH2vJkiXq3bt3ooeDOPF6verbt68WL16sMWPGaOrUqbr//vv1wgsvBP0czDQj6dx55526/vrrOz1nyJAhcrlcbRYMHD16VI2NjXK5XJ0+fv/+/SouLlb37t311ltvqWvXrpEOGzHQu3dvpaena/fu3QHHd+/e3eF77HK5Qjof1hLOe+6zcOFCPf744/rggw90xhlnxHKYiKJQ3/Pa2lpt27ZNkydP9h/zer2SWu42bt68WUOHDo3toBGRcH7Pc3Jy1LVrV6Wnp/uPjRw5UvX19Tp8+LAyMjJMvy8zzUg6ffr00YgRIzr9yMjIUGFhofbt26eNGzf6H7tmzRp5vV4VFBR0+PxNTU265JJLlJGRoXfeeYfZKAvLyMjQmDFjtHr1av8xr9er1atXq7CwsN3HFBYWBpwvSatWrerwfFhLOO+5JD355JN6+OGHVVZWFrDOAdYX6ns+YsQI/fWvf1VVVZX/45/+6Z900UUXqaqqSgMHDozn8BGGcH7Pzz//fG3ZssV/gSRJX375pXJycoIKzJLonoHUVlxcbJx99tnGhg0bjA8//NA49dRTjWnTpvm//s033xjDhw83NmzYYBiGYbjdbqOgoMA4/fTTjS1bthh1dXX+j6NHjybqZaATK1asMDIzM42lS5caNTU1xo033mj06NHDqK+vNwzDMH784x8b9957r//8jz76yOjSpYuxcOFC44svvjBKSkqMrl27Gn/9618T9RIQolDf88cff9zIyMgw/uM//iPgd3r//v2JegkIUajveWt0z7CfUN/z7du3G927dzduueUWY/Pmzca7775r9O3b13jkkUeC/p6EZqS0hoYGY9q0acYJJ5xgZGdnGzNnzgz4h3Lr1q2GJGPt2rWGYRjG2rVrDUntfmzdujUxLwKm/u3f/s04+eSTjYyMDCM/P99Yv369/2sXXHCBcd111wWc//vf/9447bTTjIyMDGPUqFHGH//4xziPGJEK5T0/5ZRT2v2dLikpif/AEbZQf8+PRWi2p1Df848//tgoKCgwMjMzjSFDhhiPPvpoSBNeDsMwjJDnxQEAAIAUQk0zAAAAYILQDAAAAJggNAMAAAAmCM0AAACACUIzAAAAYILQDAAAAJggNAMAAAAmCM0AAACACUIzAAAAYILQDABJ6Prrr5fD4ZDD4VBGRoaGDRumX/ziFzp69KgkyTAMLV68WAUFBTrhhBPUo0cPjR07Vk8//bQOHjwY8FzffPONMjIylJeX1+73evTRR3XeeefpuOOOU48ePWL90gAgIQjNAJCkiouLVVdXp6+++kp33nmnHnroIf3yl7+UJP34xz/WnDlzdMUVV2jt2rWqqqrSAw88oJUrV+r9998PeJ6lS5fqqquuUlNTkzZs2NDm+xw+fFg/+tGPNHv27Li8LgBIBIdhGEaiBwEAiK7rr79e+/bt09tvv+0/dskll2j//v264447NHXqVL399tu64oorAh5nGIaamprkdDr9nw8bNky/+c1vtHbtWjU2Nmrx4sXtfs+lS5dqzpw52rdvX6xeFgAkDDPNAJAiunXrpsOHD+u1117T8OHD2wRmSXI4HP7ALElr167VwYMHVVRUpBkzZmjFihU6cOBAPIcNAJZAaAaAJGcYhj744AP913/9lyZMmKCvvvpKw4cPD+qxL730kq6++mqlp6crLy9PQ4YM0ZtvvhnjEQOA9RCaASBJvfvuuzrhhBOUlZWlSy+9VFOnTtVDDz2kYKvy9u3bpz/84Q+aMWOG/9iMGTP00ksvxWrIAGBZXRI9AABAbFx00UVatGiRMjIy1L9/f3Xp0vIn/7TTTtOmTZtMH798+XI1NzeroKDAf8wwDHm9Xn355Zc67bTTYjZ2ALAaZpoBIEkdf/zxGjZsmE4++WR/YJaka665Rl9++aVWrlzZ5jGGYcjtdktqKc248847VVVV5f/43//9X40fP14vv/xy3F4HAFgBoRkAUsxVV12lqVOnatq0aXrsscf06aef6m9/+5veffddFRUV+VvQVVZW6v/8n/+jvLy8gI9p06bp1Vdf9fd83r59u6qqqrR9+3Z5PB5/wP7uu+8S/EoBIHpoOQcASai9lnPH8nq9Wrx4sV5++WV9/vnn6tKli0499VRde+21mjVrlu6++26tWbNGn3/+eZvH1tfXa8CAAXrrrbf0T//0T7r++uv16quvtjlv7dq1uvDCC6P8ygAgMQjNAAAAgAnKMwAAAAAThGYAAADABKEZAAAAMEFoBgAAAEwQmgEAAAAThGYAAADABKEZAAAAMEFoBgAAAEwQmgEAAAAThGYAAADABKEZAAAAMPH/AALN3X9r1OPSAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "encodings = encodings.detach()\n", + "\n", + "pca = PCA(n_components=2)\n", + "\n", + "principalComponents = pca.fit_transform(encodings)\n", + "\n", + "fig = plt.figure(figsize=(8, 8))\n", + "ax = fig.add_subplot(1, 1, 1)\n", + "ax.set_title(\"Encodings\")\n", + "ax.set_xlabel('PCA1'); ax.set_ylabel('PCA2')\n", + "\n", + "ax.scatter(principalComponents[:, 0], principalComponents[:, 1])\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/predicting.ipynb b/jupyter_examples/predicting.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..e7f987545e267def4fba1c790e35c3169cf196e8 --- /dev/null +++ b/jupyter_examples/predicting.ipynb @@ -0,0 +1,538 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Predicting" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "import torch\n", + "from lightning import pytorch as pl\n", + "from pathlib import Path\n", + "\n", + "from chemprop import data, featurizers, models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change model input here" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "checkpoint_path = chemprop_dir / \"tests\" / \"data\" / \"example_model_v2_regression_mol.ckpt\" # path to the checkpoint file.\n", + "# If the checkpoint file is generated using the training notebook, it will be in the `checkpoints` folder with name similar to `checkpoints/epoch=19-step=180.ckpt`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load model" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'graph_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['graph_transform'])`.\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'output_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['output_transform'])`.\n" + ] + }, + { + "data": { + "text/plain": [ + "MPNN(\n", + " (message_passing): BondMessagePassing(\n", + " (W_i): Linear(in_features=86, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=372, out_features=300, bias=True)\n", + " (W_d): Linear(in_features=300, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " (V_d_transform): Identity()\n", + " (graph_transform): GraphTransform(\n", + " (V_transform): Identity()\n", + " (E_transform): Identity()\n", + " )\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(300, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=300, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " (output_transform): UnscaleTransform()\n", + " )\n", + " (X_d_transform): Identity()\n", + ")" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mpnn = models.MPNN.load_from_checkpoint(checkpoint_path)\n", + "mpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change predict input here" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "test_path = chemprop_dir / \"tests\" / \"data\" / \"regression\" / \"mol\" / \"mol.csv\"\n", + "smiles_column = 'smiles'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load test smiles" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smileslipo
0Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc143.54
1COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)...-1.18
2COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl3.69
3OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C...3.37
4Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N...3.10
.........
95CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C...2.20
96CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)...2.04
97CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)...4.49
98COc1ccc(Cc2c(N)n[nH]c2N)cc10.20
99CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(...2.00
\n", + "

100 rows × 2 columns

\n", + "
" + ], + "text/plain": [ + " smiles lipo\n", + "0 Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc14 3.54\n", + "1 COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)... -1.18\n", + "2 COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl 3.69\n", + "3 OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C... 3.37\n", + "4 Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N... 3.10\n", + ".. ... ...\n", + "95 CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C... 2.20\n", + "96 CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)... 2.04\n", + "97 CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)... 4.49\n", + "98 COc1ccc(Cc2c(N)n[nH]c2N)cc1 0.20\n", + "99 CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(... 2.00\n", + "\n", + "[100 rows x 2 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_test = pd.read_csv(test_path)\n", + "df_test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get smiles" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc14\n", + "1 COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)...\n", + "2 COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl\n", + "3 OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C...\n", + "4 Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N...\n", + " ... \n", + "95 CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C...\n", + "96 CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)...\n", + "97 CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)...\n", + "98 COc1ccc(Cc2c(N)n[nH]c2N)cc1\n", + "99 CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(...\n", + "Name: smiles, Length: 100, dtype: object" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "smis = df_test[smiles_column]\n", + "smis" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get molecule datapoints" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "test_data = [data.MoleculeDatapoint.from_smi(smi) for smi in smis]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get molecule dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()\n", + "test_dset = data.MoleculeDataset(test_data, featurizer=featurizer)\n", + "test_loader = data.build_dataloader(test_dset, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Set up trainer" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "GPU available: True (cuda), used: False\n", + "TPU available: False, using: 0 TPU cores\n", + "IPU available: False, using: 0 IPUs\n", + "HPU available: False, using: 0 HPUs\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/setup.py:187: GPU available but not used. You can set it by doing `Trainer(accelerator='gpu')`.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/connectors/data_connector.py:441: The 'predict_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=63` in the `DataLoader` to improve performance.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting DataLoader 0: 100%|██████████| 100/100 [00:00<00:00, 396.76it/s]\n" + ] + } + ], + "source": [ + "with torch.inference_mode():\n", + " trainer = pl.Trainer(\n", + " logger=None,\n", + " enable_progress_bar=True,\n", + " accelerator=\"cpu\",\n", + " devices=1\n", + " )\n", + " test_preds = trainer.predict(mpnn, test_loader)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smileslipopred
0Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc143.542.176904
1COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)...-1.182.148450
2COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl3.692.159459
3OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C...3.372.167359
4Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N...3.102.153605
............
95CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C...2.202.149804
96CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)...2.042.153695
97CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)...4.492.158461
98COc1ccc(Cc2c(N)n[nH]c2N)cc10.202.175282
99CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(...2.002.159477
\n", + "

100 rows × 3 columns

\n", + "
" + ], + "text/plain": [ + " smiles lipo pred\n", + "0 Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc14 3.54 2.176904\n", + "1 COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)... -1.18 2.148450\n", + "2 COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl 3.69 2.159459\n", + "3 OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C... 3.37 2.167359\n", + "4 Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N... 3.10 2.153605\n", + ".. ... ... ...\n", + "95 CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C... 2.20 2.149804\n", + "96 CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)... 2.04 2.153695\n", + "97 CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)... 4.49 2.158461\n", + "98 COc1ccc(Cc2c(N)n[nH]c2N)cc1 0.20 2.175282\n", + "99 CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(... 2.00 2.159477\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_preds = np.concatenate(test_preds, axis=0)\n", + "df_test['pred'] = test_preds\n", + "df_test" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/predicting_regression_multicomponent.ipynb b/jupyter_examples/predicting_regression_multicomponent.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..c28b6c62ca01d016a67c9fe24941da4fc8ee0ac5 --- /dev/null +++ b/jupyter_examples/predicting_regression_multicomponent.ipynb @@ -0,0 +1,587 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Predicting Regression - Multicomponent" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import torch\n", + "from lightning import pytorch as pl\n", + "from pathlib import Path\n", + "\n", + "from chemprop import data, featurizers\n", + "from chemprop.models import multi" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change model input here" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "checkpoint_path = chemprop_dir / \"tests\" / \"data\" / \"example_model_v2_regression_mol+mol.ckpt\" # path to the checkpoint file. \n", + "# If the checkpoint file is generated using the training notebook, it will be in the `checkpoints` folder with name similar to `checkpoints/epoch=19-step=180.ckpt`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load model" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'graph_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['graph_transform'])`.\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'output_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['output_transform'])`.\n" + ] + }, + { + "data": { + "text/plain": [ + "MulticomponentMPNN(\n", + " (message_passing): MulticomponentMessagePassing(\n", + " (blocks): ModuleList(\n", + " (0-1): 2 x BondMessagePassing(\n", + " (W_i): Linear(in_features=86, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=372, out_features=300, bias=True)\n", + " (W_d): Linear(in_features=300, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " (V_d_transform): Identity()\n", + " (graph_transform): GraphTransform(\n", + " (V_transform): Identity()\n", + " (E_transform): Identity()\n", + " )\n", + " )\n", + " )\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(600, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=600, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " (output_transform): UnscaleTransform()\n", + " )\n", + " (X_d_transform): Identity()\n", + ")" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcmpnn = multi.MulticomponentMPNN.load_from_checkpoint(checkpoint_path)\n", + "mcmpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change predict input here" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "test_path = chemprop_dir / \"tests\" / \"data\" / \"regression\" / \"mol+mol\" / \"mol+mol.csv\" # path to your .csv file containing SMILES strings to make predictions for\n", + "smiles_columns = ['smiles', 'solvent'] # name of the column containing SMILES strings" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load test smiles" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smilessolventpeakwavs_max
0CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2C...ClCCl642.0
1C(=C/c1cnccn1)\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3c...ClCCl420.0
2CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+]...O544.0
3c1ccc2[nH]ccc2c1O290.0
4CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5c...ClC(Cl)Cl736.0
............
95COc1ccc(C2CC(c3ccc(O)cc3)=NN2c2ccc(S(N)(=O)=O)...C1CCOC1359.0
96COc1ccc2c3c(c4ccc(OC)cc4c2c1)C1(c2ccccc2-c2ccc...C1CCCCC1386.0
97CCCCOc1c(C=C2N(C)c3ccccc3C2(C)C)c(=O)c1=OCCO425.0
98Cc1cc2ccc(-c3cccc4cccc(-c5ccc6cc(C)c(=O)oc6c5)...c1ccccc1324.0
99Cc1ccc(C(=O)c2c(C)c3ccc4cccc5c6cccc7ccc2c(c76)...ClCCl391.0
\n", + "

100 rows × 3 columns

\n", + "
" + ], + "text/plain": [ + " smiles solvent peakwavs_max\n", + "0 CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2C... ClCCl 642.0\n", + "1 C(=C/c1cnccn1)\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3c... ClCCl 420.0\n", + "2 CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+]... O 544.0\n", + "3 c1ccc2[nH]ccc2c1 O 290.0\n", + "4 CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5c... ClC(Cl)Cl 736.0\n", + ".. ... ... ...\n", + "95 COc1ccc(C2CC(c3ccc(O)cc3)=NN2c2ccc(S(N)(=O)=O)... C1CCOC1 359.0\n", + "96 COc1ccc2c3c(c4ccc(OC)cc4c2c1)C1(c2ccccc2-c2ccc... C1CCCCC1 386.0\n", + "97 CCCCOc1c(C=C2N(C)c3ccccc3C2(C)C)c(=O)c1=O CCO 425.0\n", + "98 Cc1cc2ccc(-c3cccc4cccc(-c5ccc6cc(C)c(=O)oc6c5)... c1ccccc1 324.0\n", + "99 Cc1ccc(C(=O)c2c(C)c3ccc4cccc5c6cccc7ccc2c(c76)... ClCCl 391.0\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_test = pd.read_csv(test_path)\n", + "df_test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get smiles" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([['CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2CCCC)C(=O)N(CCCC)C1=S',\n", + " 'ClCCl'],\n", + " ['C(=C/c1cnccn1)\\\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3cnccn3)cc2)cc1',\n", + " 'ClCCl'],\n", + " ['CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+](C)C)cc-3oc2c1',\n", + " 'O'],\n", + " ['c1ccc2[nH]ccc2c1', 'O'],\n", + " ['CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5ccccc5c4C3(C)C)CCCC1=C2c1ccccc1C(=O)O',\n", + " 'ClC(Cl)Cl']], dtype=object)" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "smiss = df_test[smiles_columns].values\n", + "smiss[:5]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get molecule datapoints" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "n_componenets = len(smiles_columns)\n", + "test_datapointss = [[data.MoleculeDatapoint.from_smi(smi) for smi in smiss[:, i]] for i in range(n_componenets)]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get molecule datasets" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()\n", + "test_dsets = [data.MoleculeDataset(test_datapoints, featurizer) for test_datapoints in test_datapointss]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get multicomponent dataset and data loader" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "test_mcdset = data.MulticomponentDataset(test_dsets)\n", + "test_loader = data.build_dataloader(test_mcdset, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Set up trainer" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "GPU available: True (cuda), used: True\n", + "TPU available: False, using: 0 TPU cores\n", + "IPU available: False, using: 0 IPUs\n", + "HPU available: False, using: 0 HPUs\n", + "LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0,1]\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/connectors/data_connector.py:441: The 'predict_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=63` in the `DataLoader` to improve performance.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting DataLoader 0: 100%|██████████| 100/100 [00:00<00:00, 399.94it/s]\n" + ] + } + ], + "source": [ + "with torch.inference_mode():\n", + " trainer = pl.Trainer(\n", + " logger=None,\n", + " enable_progress_bar=True,\n", + " accelerator=\"auto\",\n", + " devices=1\n", + " )\n", + " test_preds = trainer.predict(mcmpnn, test_loader)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smilessolventpeakwavs_maxpred
0CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2C...ClCCl642.0458.408508
1C(=C/c1cnccn1)\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3c...ClCCl420.0457.399109
2CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+]...O544.0453.458466
3c1ccc2[nH]ccc2c1O290.0453.070251
4CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5c...ClC(Cl)Cl736.0461.637939
...............
95COc1ccc(C2CC(c3ccc(O)cc3)=NN2c2ccc(S(N)(=O)=O)...C1CCOC1359.0459.446198
96COc1ccc2c3c(c4ccc(OC)cc4c2c1)C1(c2ccccc2-c2ccc...C1CCCCC1386.0462.069153
97CCCCOc1c(C=C2N(C)c3ccccc3C2(C)C)c(=O)c1=OCCO425.0458.131134
98Cc1cc2ccc(-c3cccc4cccc(-c5ccc6cc(C)c(=O)oc6c5)...c1ccccc1324.0459.271179
99Cc1ccc(C(=O)c2c(C)c3ccc4cccc5c6cccc7ccc2c(c76)...ClCCl391.0458.653809
\n", + "

100 rows × 4 columns

\n", + "
" + ], + "text/plain": [ + " smiles solvent \\\n", + "0 CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2C... ClCCl \n", + "1 C(=C/c1cnccn1)\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3c... ClCCl \n", + "2 CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+]... O \n", + "3 c1ccc2[nH]ccc2c1 O \n", + "4 CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5c... ClC(Cl)Cl \n", + ".. ... ... \n", + "95 COc1ccc(C2CC(c3ccc(O)cc3)=NN2c2ccc(S(N)(=O)=O)... C1CCOC1 \n", + "96 COc1ccc2c3c(c4ccc(OC)cc4c2c1)C1(c2ccccc2-c2ccc... C1CCCCC1 \n", + "97 CCCCOc1c(C=C2N(C)c3ccccc3C2(C)C)c(=O)c1=O CCO \n", + "98 Cc1cc2ccc(-c3cccc4cccc(-c5ccc6cc(C)c(=O)oc6c5)... c1ccccc1 \n", + "99 Cc1ccc(C(=O)c2c(C)c3ccc4cccc5c6cccc7ccc2c(c76)... ClCCl \n", + "\n", + " peakwavs_max pred \n", + "0 642.0 458.408508 \n", + "1 420.0 457.399109 \n", + "2 544.0 453.458466 \n", + "3 290.0 453.070251 \n", + "4 736.0 461.637939 \n", + ".. ... ... \n", + "95 359.0 459.446198 \n", + "96 386.0 462.069153 \n", + "97 425.0 458.131134 \n", + "98 324.0 459.271179 \n", + "99 391.0 458.653809 \n", + "\n", + "[100 rows x 4 columns]" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_preds = np.concatenate(test_preds, axis=0)\n", + "df_test['pred'] = test_preds\n", + "df_test" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/predicting_regression_reaction.ipynb b/jupyter_examples/predicting_regression_reaction.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..3cdf56c85d52cb1abe871b5a05ecbe96cff31bb7 --- /dev/null +++ b/jupyter_examples/predicting_regression_reaction.ipynb @@ -0,0 +1,432 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Predicting Regression - Reaction" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import numpy as np\n", + "import torch\n", + "from lightning import pytorch as pl\n", + "from pathlib import Path\n", + "\n", + "from chemprop import data, featurizers, models" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change model input here" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "checkpoint_path = chemprop_dir / \"tests\" / \"data\" / \"example_model_v2_regression_rxn.ckpt\" # path to the checkpoint file.\n", + "# If the checkpoint file is generated using the training notebook, it will be in the `checkpoints` folder with name similar to `checkpoints/epoch=19-step=180.ckpt`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load model" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'graph_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['graph_transform'])`.\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'output_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['output_transform'])`.\n" + ] + }, + { + "data": { + "text/plain": [ + "MPNN(\n", + " (message_passing): BondMessagePassing(\n", + " (W_i): Linear(in_features=134, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=406, out_features=300, bias=True)\n", + " (W_d): Linear(in_features=300, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " (V_d_transform): Identity()\n", + " (graph_transform): GraphTransform(\n", + " (V_transform): Identity()\n", + " (E_transform): Identity()\n", + " )\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(300, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=300, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " (output_transform): UnscaleTransform()\n", + " )\n", + " (X_d_transform): Identity()\n", + ")" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mpnn = models.MPNN.load_from_checkpoint(checkpoint_path)\n", + "mpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change predict input here" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "test_path = chemprop_dir / \"tests\" / \"data\" / \"regression\" / \"rxn\" / \"rxn.csv\"\n", + "smiles_column = 'smiles'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load smiles" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array(['[O:1]([C:2]([C:3]([C:4](=[O:5])[C:6]([O:7][H:15])([H:13])[H:14])([H:11])[H:12])([H:9])[H:10])[H:8]>>[C:3](=[C:4]=[O:5])([H:11])[H:12].[C:6]([O:7][H:15])([H:8])([H:13])[H:14].[O:1]=[C:2]([H:9])[H:10]',\n", + " '[C:1]1([H:8])([H:9])[O:2][C@@:3]2([H:10])[C@@:4]3([H:11])[O:5][C@:6]1([H:12])[C@@:7]23[H:13]>>[C:1]1([H:8])([H:9])[O:2][C:3]([H:10])=[C:7]([H:13])[C@:6]1([O+:5]=[C-:4][H:11])[H:12]',\n", + " '[C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H:13])([H:14])[C:5]([H:15])=[C:6]([H:16])[C@@:7]12[H:17])([H:8])([H:9])[H:10]>>[C:1]([C@@:2]1([H:11])[C:3]([H:12])([H:13])[C:4]([H:14])=[C:5]([H:15])[C:6]([H:16])=[C:7]1[H:17])([H:8])([H:9])[H:10]',\n", + " '[C:1]([O:2][C:3]([C@@:4]([C:5]([H:14])([H:15])[H:16])([C:6]([O:7][H:19])([H:17])[H:18])[H:13])([H:11])[H:12])([H:8])([H:9])[H:10]>>[C-:1]([O+:2]=[C:3]([C@@:4]([C:5]([H:14])([H:15])[H:16])([C:6]([O:7][H:19])([H:17])[H:18])[H:13])[H:12])([H:8])[H:10].[H:9][H:11]',\n", + " '[C:1]([C:2]#[C:3][C:4]([C:5](=[O:6])[H:12])([H:10])[H:11])([H:7])([H:8])[H:9]>>[C:1]([C:2](=[C:3]=[C:4]([H:10])[H:11])[C:5](=[O:6])[H:12])([H:7])([H:8])[H:9]'],\n", + " dtype=object)" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_test = pd.read_csv(test_path)\n", + "\n", + "smis = df_test.loc[:, smiles_column].values\n", + "smis[:5]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load datapoints" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "test_data = [data.ReactionDatapoint.from_smi(smi) for smi in smis]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define featurizer" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.CondensedGraphOfReactionFeaturizer(mode_=\"PROD_DIFF\")\n", + "# Testing parameters should match training parameters" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get dataset and dataloader" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "test_dset = data.ReactionDataset(test_data, featurizer=featurizer)\n", + "test_loader = data.build_dataloader(test_dset, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Perform tests" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "GPU available: True (cuda), used: False\n", + "TPU available: False, using: 0 TPU cores\n", + "IPU available: False, using: 0 IPUs\n", + "HPU available: False, using: 0 HPUs\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/setup.py:187: GPU available but not used. You can set it by doing `Trainer(accelerator='gpu')`.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/connectors/data_connector.py:441: The 'predict_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=63` in the `DataLoader` to improve performance.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Predicting DataLoader 0: 100%|██████████| 100/100 [00:00<00:00, 613.08it/s]\n" + ] + } + ], + "source": [ + "with torch.inference_mode():\n", + " trainer = pl.Trainer(\n", + " logger=None,\n", + " enable_progress_bar=True,\n", + " accelerator=\"cpu\",\n", + " devices=1\n", + " )\n", + " test_preds = trainer.predict(mpnn, test_loader)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smileseapreds
0[O:1]([C:2]([C:3]([C:4](=[O:5])[C:6]([O:7][H:1...8.8989348.010366
1[C:1]1([H:8])([H:9])[O:2][C@@:3]2([H:10])[C@@:...5.4643288.075241
2[C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H...5.2705528.069977
3[C:1]([O:2][C:3]([C@@:4]([C:5]([H:14])([H:15])...8.4730068.023890
4[C:1]([C:2]#[C:3][C:4]([C:5](=[O:6])[H:12])([H...5.5790378.040219
............
95[C:1]([C:2]([C:3]([H:12])([H:13])[H:14])([C:4]...9.2956658.025584
96[O:1]=[C:2]([C@@:3]1([H:9])[C:4]([H:10])([H:11...7.7534428.039022
97[C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H...10.6502158.082537
98[C:1]1([H:8])([H:9])[C@@:2]2([H:10])[N:3]1[C:4...10.1389458.170304
99[C:1]([C@@:2]1([C:3]([C:4]([O:5][H:15])([H:13]...6.9799348.068456
\n", + "

100 rows × 3 columns

\n", + "
" + ], + "text/plain": [ + " smiles ea preds\n", + "0 [O:1]([C:2]([C:3]([C:4](=[O:5])[C:6]([O:7][H:1... 8.898934 8.010366\n", + "1 [C:1]1([H:8])([H:9])[O:2][C@@:3]2([H:10])[C@@:... 5.464328 8.075241\n", + "2 [C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H... 5.270552 8.069977\n", + "3 [C:1]([O:2][C:3]([C@@:4]([C:5]([H:14])([H:15])... 8.473006 8.023890\n", + "4 [C:1]([C:2]#[C:3][C:4]([C:5](=[O:6])[H:12])([H... 5.579037 8.040219\n", + ".. ... ... ...\n", + "95 [C:1]([C:2]([C:3]([H:12])([H:13])[H:14])([C:4]... 9.295665 8.025584\n", + "96 [O:1]=[C:2]([C@@:3]1([H:9])[C:4]([H:10])([H:11... 7.753442 8.039022\n", + "97 [C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H... 10.650215 8.082537\n", + "98 [C:1]1([H:8])([H:9])[C@@:2]2([H:10])[N:3]1[C:4... 10.138945 8.170304\n", + "99 [C:1]([C@@:2]1([C:3]([C:4]([O:5][H:15])([H:13]... 6.979934 8.068456\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_preds = np.concatenate(test_preds, axis=0)\n", + "df_test['preds'] = test_preds\n", + "df_test" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/training.ipynb b/jupyter_examples/training.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..8b569dd6d6c9f399b1cc4c854f280c4937214ff0 --- /dev/null +++ b/jupyter_examples/training.ipynb @@ -0,0 +1,814 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Training" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from pathlib import Path\n", + "\n", + "from lightning import pytorch as pl\n", + "\n", + "from chemprop import data, featurizers, models, nn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change data inputs here" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "input_path = chemprop_dir / \"tests\" / \"data\" / \"regression\" / \"mol\" / \"mol.csv\" # path to your data .csv file\n", + "num_workers = 0 # number of workers for dataloader. 0 means using main process for data loading\n", + "smiles_column = 'smiles' # name of the column containing SMILES strings\n", + "target_columns = ['lipo'] # list of names of the columns containing targets" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load data" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smileslipo
0Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc143.54
1COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)...-1.18
2COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl3.69
3OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C...3.37
4Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N...3.10
.........
95CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C...2.20
96CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)...2.04
97CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)...4.49
98COc1ccc(Cc2c(N)n[nH]c2N)cc10.20
99CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(...2.00
\n", + "

100 rows × 2 columns

\n", + "
" + ], + "text/plain": [ + " smiles lipo\n", + "0 Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc14 3.54\n", + "1 COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)... -1.18\n", + "2 COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl 3.69\n", + "3 OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(C... 3.37\n", + "4 Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)N... 3.10\n", + ".. ... ...\n", + "95 CC(C)N(CCCNC(=O)Nc1ccc(cc1)C(C)(C)C)C[C@H]2O[C... 2.20\n", + "96 CCN(CC)CCCCNc1ncc2CN(C(=O)N(Cc3cccc(NC(=O)C=C)... 2.04\n", + "97 CCSc1c(Cc2ccccc2C(F)(F)F)sc3N(CC(C)C)C(=O)N(C)... 4.49\n", + "98 COc1ccc(Cc2c(N)n[nH]c2N)cc1 0.20\n", + "99 CCN(CCN(C)C)S(=O)(=O)c1ccc(cc1)c2cnc(N)c(n2)C(... 2.00\n", + "\n", + "[100 rows x 2 columns]" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_input = pd.read_csv(input_path)\n", + "df_input" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get SMILES and targets" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "smis = df_input.loc[:, smiles_column].values\n", + "ys = df_input.loc[:, target_columns].values" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array(['Cn1c(CN2CCN(CC2)c3ccc(Cl)cc3)nc4ccccc14',\n", + " 'COc1cc(OC)c(cc1NC(=O)CSCC(=O)O)S(=O)(=O)N2C(C)CCc3ccccc23',\n", + " 'COC(=O)[C@@H](N1CCc2sccc2C1)c3ccccc3Cl',\n", + " 'OC[C@H](O)CN1C(=O)C(Cc2ccccc12)NC(=O)c3cc4cc(Cl)sc4[nH]3',\n", + " 'Cc1cccc(C[C@H](NC(=O)c2cc(nn2C)C(C)(C)C)C(=O)NCC#N)c1'],\n", + " dtype=object)" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "smis[:5] # show first 5 SMILES strings" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[ 3.54],\n", + " [-1.18],\n", + " [ 3.69],\n", + " [ 3.37],\n", + " [ 3.1 ]])" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ys[:5] # show first 5 targets" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get molecule datapoints" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "all_data = [data.MoleculeDatapoint.from_smi(smi, y) for smi, y in zip(smis, ys)]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Perform data splitting for training, validation, and testing" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['CV_NO_VAL',\n", + " 'CV',\n", + " 'SCAFFOLD_BALANCED',\n", + " 'RANDOM_WITH_REPEATED_SMILES',\n", + " 'RANDOM',\n", + " 'KENNARD_STONE',\n", + " 'KMEANS']" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# available split types\n", + "list(data.SplitType.keys())" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [], + "source": [ + "mols = [d.mol for d in all_data] # RDkit Mol objects are use for structure based splits\n", + "train_indices, val_indices, test_indices = data.make_split_indices(mols, \"random\", (0.8, 0.1, 0.1))\n", + "train_data, val_data, test_data = data.split_data_by_indices(\n", + " all_data, train_indices, val_indices, test_indices\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get MoleculeDataset" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()\n", + "\n", + "train_dset = data.MoleculeDataset(train_data, featurizer)\n", + "scaler = train_dset.normalize_targets()\n", + "\n", + "val_dset = data.MoleculeDataset(val_data, featurizer)\n", + "val_dset.normalize_targets(scaler)\n", + "\n", + "test_dset = data.MoleculeDataset(test_data, featurizer)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get DataLoader" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [], + "source": [ + "train_loader = data.build_dataloader(train_dset, num_workers=num_workers)\n", + "val_loader = data.build_dataloader(val_dset, num_workers=num_workers, shuffle=False)\n", + "test_loader = data.build_dataloader(test_dset, num_workers=num_workers, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change Message-Passing Neural Network (MPNN) inputs here" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Message Passing\n", + "A `Message passing` constructs molecular graphs using message passing to learn node-level hidden representations.\n", + "\n", + "Options are `mp = nn.BondMessagePassing()` or `mp = nn.AtomMessagePassing()`" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [], + "source": [ + "mp = nn.BondMessagePassing()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Aggregation\n", + "An `Aggregation` is responsible for constructing a graph-level representation from the set of node-level representations after message passing.\n", + "\n", + "Available options can be found in ` nn.agg.AggregationRegistry`, including\n", + "- `agg = nn.MeanAggregation()`\n", + "- `agg = nn.SumAggregation()`\n", + "- `agg = nn.NormAggregation()`" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ClassRegistry {\n", + " 'mean': ,\n", + " 'sum': ,\n", + " 'norm': \n", + "}\n" + ] + } + ], + "source": [ + "print(nn.agg.AggregationRegistry)" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [], + "source": [ + "agg = nn.MeanAggregation()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Feed-Forward Network (FFN)\n", + "\n", + "A `FFN` takes the aggregated representations and make target predictions.\n", + "\n", + "Available options can be found in `nn.PredictorRegistry`.\n", + "\n", + "For regression:\n", + "- `ffn = nn.RegressionFFN()`\n", + "- `ffn = nn.MveFFN()`\n", + "- `ffn = nn.EvidentialFFN()`\n", + "\n", + "For classification:\n", + "- `ffn = nn.BinaryClassificationFFN()`\n", + "- `ffn = nn.BinaryDirichletFFN()`\n", + "- `ffn = nn.MulticlassClassificationFFN()`\n", + "- `ffn = nn.MulticlassDirichletFFN()`\n", + "\n", + "For spectral:\n", + "- `ffn = nn.SpectralFFN()` # will be available in future version" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ClassRegistry {\n", + " 'regression': ,\n", + " 'regression-mve': ,\n", + " 'regression-evidential': ,\n", + " 'classification': ,\n", + " 'classification-dirichlet': ,\n", + " 'multiclass': ,\n", + " 'multiclass-dirichlet': ,\n", + " 'spectral': \n", + "}\n" + ] + } + ], + "source": [ + "print(nn.PredictorRegistry)" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/Projects/chemprop_v2_dev/chemprop/chemprop/nn/transforms.py:21: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).\n", + " self.register_buffer(\"mean\", torch.tensor(mean, dtype=torch.float).unsqueeze(0))\n", + "/home/hwpang/Projects/chemprop_v2_dev/chemprop/chemprop/nn/transforms.py:22: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).\n", + " self.register_buffer(\"scale\", torch.tensor(scale, dtype=torch.float).unsqueeze(0))\n" + ] + } + ], + "source": [ + "output_transform = nn.UnscaleTransform.from_standard_scaler(scaler)" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'output_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['output_transform'])`.\n" + ] + } + ], + "source": [ + "ffn = nn.RegressionFFN(output_transform=output_transform)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Batch Norm\n", + "A `Batch Norm` normalizes the outputs of the aggregation by re-centering and re-scaling.\n", + "\n", + "Whether to use batch norm" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [], + "source": [ + "batch_norm = True" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Metrics\n", + "`Metrics` are the ways to evaluate the performance of model predictions.\n", + "\n", + "Available options can be found in `metrics.MetricRegistry`, including" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ClassRegistry {\n", + " 'mae': ,\n", + " 'mse': ,\n", + " 'rmse': ,\n", + " 'bounded-mae': ,\n", + " 'bounded-mse': ,\n", + " 'bounded-rmse': ,\n", + " 'r2': ,\n", + " 'roc': ,\n", + " 'prc': ,\n", + " 'accuracy': ,\n", + " 'f1': ,\n", + " 'bce': ,\n", + " 'ce': ,\n", + " 'binary-mcc': ,\n", + " 'multiclass-mcc': ,\n", + " 'sid': ,\n", + " 'wasserstein': \n", + "}\n" + ] + } + ], + "source": [ + "print(nn.metrics.MetricRegistry)" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [], + "source": [ + "metric_list = [nn.metrics.RMSEMetric(), nn.metrics.MAEMetric()] # Only the first metric is used for training and early stopping" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Constructs MPNN" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MPNN(\n", + " (message_passing): BondMessagePassing(\n", + " (W_i): Linear(in_features=86, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=372, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " (V_d_transform): Identity()\n", + " (graph_transform): Identity()\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(300, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=300, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " (output_transform): UnscaleTransform()\n", + " )\n", + " (X_d_transform): Identity()\n", + ")" + ] + }, + "execution_count": 39, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mpnn = models.MPNN(mp, agg, ffn, batch_norm, metric_list)\n", + "\n", + "mpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Set up trainer" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "GPU available: True (cuda), used: True\n", + "TPU available: False, using: 0 TPU cores\n", + "IPU available: False, using: 0 IPUs\n", + "HPU available: False, using: 0 HPUs\n" + ] + } + ], + "source": [ + "trainer = pl.Trainer(\n", + " logger=False,\n", + " enable_checkpointing=True, # Use `True` if you want to save model checkpoints. The checkpoints will be saved in the `checkpoints` folder.\n", + " enable_progress_bar=True,\n", + " accelerator=\"auto\",\n", + " devices=1,\n", + " max_epochs=20, # number of epochs to train for\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Start training" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "You are using a CUDA device ('NVIDIA GeForce RTX 4090') that has Tensor Cores. To properly utilize them, you should set `torch.set_float32_matmul_precision('medium' | 'high')` which will trade-off precision for performance. For more details, read https://pytorch.org/docs/stable/generated/torch.set_float32_matmul_precision.html#torch.set_float32_matmul_precision\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/callbacks/model_checkpoint.py:653: Checkpoint directory /home/hwpang/Projects/chemprop_v2_dev/chemprop/examples/checkpoints exists and is not empty.\n", + "LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0,1]\n", + "Loading `train_dataloader` to estimate number of stepping batches.\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/connectors/data_connector.py:441: The 'train_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=63` in the `DataLoader` to improve performance.\n", + "\n", + " | Name | Type | Params\n", + "-------------------------------------------------------\n", + "0 | message_passing | BondMessagePassing | 227 K \n", + "1 | agg | MeanAggregation | 0 \n", + "2 | bn | BatchNorm1d | 600 \n", + "3 | predictor | RegressionFFN | 90.6 K\n", + "4 | X_d_transform | Identity | 0 \n", + " | other params | n/a | 1 \n", + "-------------------------------------------------------\n", + "318 K Trainable params\n", + "1 Non-trainable params\n", + "318 K Total params\n", + "1.276 Total estimated model params size (MB)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Sanity Checking DataLoader 0: 0%| | 0/1 [00:00\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smilessolventpeakwavs_max
0CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2C...ClCCl642.0
1C(=C/c1cnccn1)\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3c...ClCCl420.0
2CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+]...O544.0
3c1ccc2[nH]ccc2c1O290.0
4CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5c...ClC(Cl)Cl736.0
............
95COc1ccc(C2CC(c3ccc(O)cc3)=NN2c2ccc(S(N)(=O)=O)...C1CCOC1359.0
96COc1ccc2c3c(c4ccc(OC)cc4c2c1)C1(c2ccccc2-c2ccc...C1CCCCC1386.0
97CCCCOc1c(C=C2N(C)c3ccccc3C2(C)C)c(=O)c1=OCCO425.0
98Cc1cc2ccc(-c3cccc4cccc(-c5ccc6cc(C)c(=O)oc6c5)...c1ccccc1324.0
99Cc1ccc(C(=O)c2c(C)c3ccc4cccc5c6cccc7ccc2c(c76)...ClCCl391.0
\n", + "

100 rows × 3 columns

\n", + "" + ], + "text/plain": [ + " smiles solvent peakwavs_max\n", + "0 CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2C... ClCCl 642.0\n", + "1 C(=C/c1cnccn1)\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3c... ClCCl 420.0\n", + "2 CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+]... O 544.0\n", + "3 c1ccc2[nH]ccc2c1 O 290.0\n", + "4 CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5c... ClC(Cl)Cl 736.0\n", + ".. ... ... ...\n", + "95 COc1ccc(C2CC(c3ccc(O)cc3)=NN2c2ccc(S(N)(=O)=O)... C1CCOC1 359.0\n", + "96 COc1ccc2c3c(c4ccc(OC)cc4c2c1)C1(c2ccccc2-c2ccc... C1CCCCC1 386.0\n", + "97 CCCCOc1c(C=C2N(C)c3ccccc3C2(C)C)c(=O)c1=O CCO 425.0\n", + "98 Cc1cc2ccc(-c3cccc4cccc(-c5ccc6cc(C)c(=O)oc6c5)... c1ccccc1 324.0\n", + "99 Cc1ccc(C(=O)c2c(C)c3ccc4cccc5c6cccc7ccc2c(c76)... ClCCl 391.0\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_input = pd.read_csv(input_path)\n", + "df_input" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get SMILES and targets" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "smiss = df_input.loc[:, smiles_columns].values\n", + "ys = df_input.loc[:, target_columns].values" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(array([['CCCCN1C(=O)C(=C/C=C/C=C/C=C2N(CCCC)c3ccccc3N2CCCC)C(=O)N(CCCC)C1=S',\n", + " 'ClCCl'],\n", + " ['C(=C/c1cnccn1)\\\\c1ccc(N(c2ccccc2)c2ccc(/C=C/c3cnccn3)cc2)cc1',\n", + " 'ClCCl'],\n", + " ['CN(C)c1ccc2c(-c3ccc(N)cc3C(=O)[O-])c3ccc(=[N+](C)C)cc-3oc2c1',\n", + " 'O'],\n", + " ['c1ccc2[nH]ccc2c1', 'O'],\n", + " ['CCN(CC)c1ccc2c(c1)OC1=C(/C=C/C3=[N+](C)c4ccc5ccccc5c4C3(C)C)CCCC1=C2c1ccccc1C(=O)O',\n", + " 'ClC(Cl)Cl']], dtype=object),\n", + " array([[642.],\n", + " [420.],\n", + " [544.],\n", + " [290.],\n", + " [736.]]))" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Take a look at the first 5 SMILES strings and targets\n", + "smiss[:5], ys[:5]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Make molecule datapoints\n", + "Create a list of lists containing the molecule datapoints for each components. The target is stored in the 0th component." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "all_data = [[data.MoleculeDatapoint.from_smi(smis[0], y) for smis, y in zip(smiss, ys)]]\n", + "all_data += [[data.MoleculeDatapoint.from_smi(smis[i]) for smis in smiss] for i in range(1, len(smiles_columns))]\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Split data" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Perform data splitting for training, validation, and testing" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "component_to_split_by = 0 # index of the component to use for structure based splits\n", + "mols = [d.mol for d in all_data[component_to_split_by]]\n", + "train_indices, val_indices, test_indices = data.make_split_indices(mols, \"random\", (0.8, 0.1, 0.1))\n", + "train_data, val_data, test_data = data.split_data_by_indices(\n", + " all_data, train_indices, val_indices, test_indices\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Get MoleculeDataset for each components" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()\n", + "\n", + "train_datasets = [data.MoleculeDataset(train_data[i], featurizer) for i in range(len(smiles_columns))]\n", + "val_datasets = [data.MoleculeDataset(val_data[i], featurizer) for i in range(len(smiles_columns))]\n", + "test_datasets = [data.MoleculeDataset(test_data[i], featurizer) for i in range(len(smiles_columns))]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Construct multicomponent dataset and scale the targets" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "train_mcdset = data.MulticomponentDataset(train_datasets)\n", + "scaler = train_mcdset.normalize_targets()\n", + "val_mcdset = data.MulticomponentDataset(val_datasets)\n", + "val_mcdset.normalize_targets(scaler)\n", + "test_mcdset = data.MulticomponentDataset(test_datasets)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Construct data loader" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "train_loader = data.build_dataloader(train_mcdset)\n", + "val_loader = data.build_dataloader(val_mcdset, shuffle=False)\n", + "test_loader = data.build_dataloader(test_mcdset, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Construct multicomponent MPNN" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## MulticomponentMessagePassing\n", + "- `blocks`: a list of message passing block used for each components\n", + "- `n_components`: number of components" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "mcmp = nn.MulticomponentMessagePassing(\n", + " blocks=[nn.BondMessagePassing() for _ in range(len(smiles_columns))],\n", + " n_components=len(smiles_columns),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Aggregation" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "agg = nn.MeanAggregation()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RegressionFFN" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/Projects/chemprop_v2_dev/chemprop/chemprop/nn/transforms.py:21: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).\n", + " self.register_buffer(\"mean\", torch.tensor(mean, dtype=torch.float).unsqueeze(0))\n", + "/home/hwpang/Projects/chemprop_v2_dev/chemprop/chemprop/nn/transforms.py:22: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).\n", + " self.register_buffer(\"scale\", torch.tensor(scale, dtype=torch.float).unsqueeze(0))\n" + ] + } + ], + "source": [ + "output_transform = nn.UnscaleTransform.from_standard_scaler(scaler)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'output_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['output_transform'])`.\n" + ] + } + ], + "source": [ + "ffn = nn.RegressionFFN(\n", + " input_dim=mcmp.output_dim,\n", + " output_transform=output_transform,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Metrics" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "metric_list = [metrics.RMSEMetric(), metrics.MAEMetric()] # Only the first metric is used for training and early stopping" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## MulticomponentMPNN" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MulticomponentMPNN(\n", + " (message_passing): MulticomponentMessagePassing(\n", + " (blocks): ModuleList(\n", + " (0-1): 2 x BondMessagePassing(\n", + " (W_i): Linear(in_features=86, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=372, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " (V_d_transform): Identity()\n", + " (graph_transform): Identity()\n", + " )\n", + " )\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(600, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=600, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " (output_transform): UnscaleTransform()\n", + " )\n", + " (X_d_transform): Identity()\n", + ")" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mcmpnn = multi.MulticomponentMPNN(\n", + " mcmp,\n", + " agg,\n", + " ffn,\n", + " metrics=metric_list,\n", + ")\n", + "\n", + "mcmpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Set up trainer" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "GPU available: True (cuda), used: True\n", + "TPU available: False, using: 0 TPU cores\n", + "IPU available: False, using: 0 IPUs\n", + "HPU available: False, using: 0 HPUs\n" + ] + } + ], + "source": [ + "trainer = pl.Trainer(\n", + " logger=False,\n", + " enable_checkpointing=True,\n", + " enable_progress_bar=True,\n", + " accelerator=\"auto\",\n", + " devices=1,\n", + " max_epochs=20, # number of epochs to train for\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Start training" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0,1]\n", + "Loading `train_dataloader` to estimate number of stepping batches.\n", + "\n", + " | Name | Type | Params\n", + "-----------------------------------------------------------------\n", + "0 | message_passing | MulticomponentMessagePassing | 455 K \n", + "1 | agg | MeanAggregation | 0 \n", + "2 | bn | BatchNorm1d | 1.2 K \n", + "3 | predictor | RegressionFFN | 180 K \n", + "4 | X_d_transform | Identity | 0 \n", + " | other params | n/a | 1 \n", + "-----------------------------------------------------------------\n", + "637 K Trainable params\n", + "1 Non-trainable params\n", + "637 K Total params\n", + "2.549 Total estimated model params size (MB)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 19: 100%|██████████| 2/2 [00:00<00:00, 25.37it/s, train_loss=0.033, val_loss=480.0] " + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "`Trainer.fit` stopped: `max_epochs=20` reached.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch 19: 100%|██████████| 2/2 [00:00<00:00, 21.88it/s, train_loss=0.033, val_loss=480.0]\n" + ] + } + ], + "source": [ + "trainer.fit(mcmpnn, train_loader, val_loader)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test results" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0,1]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Testing DataLoader 0: 100%|██████████| 1/1 [00:00<00:00, 239.66it/s]\n", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n", + " Test metric DataLoader 0\n", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n", + " test/mae 66.03467559814453\n", + " test/rmse 78.33834838867188\n", + "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n" + ] + } + ], + "source": [ + "results = trainer.test(mcmpnn, test_loader)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.8" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/jupyter_examples/training_regression_reaction.ipynb b/jupyter_examples/training_regression_reaction.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..ea70036cb1d308632b991cc0c21ba81d97b1c43f --- /dev/null +++ b/jupyter_examples/training_regression_reaction.ipynb @@ -0,0 +1,782 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Training Regression - Reaction" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "from lightning import pytorch as pl\n", + "from pathlib import Path\n", + "\n", + "from chemprop import data, featurizers, models, nn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change data inputs here" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "chemprop_dir = Path.cwd().parent\n", + "input_path = chemprop_dir / \"tests\" / \"data\" / \"regression\" / \"rxn\" / \"rxn.csv\"\n", + "num_workers = 0 # number of workers for dataloader. 0 means using main process for data loading\n", + "smiles_column = 'smiles'\n", + "target_columns = ['ea']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load data" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
smilesea
0[O:1]([C:2]([C:3]([C:4](=[O:5])[C:6]([O:7][H:1...8.898934
1[C:1]1([H:8])([H:9])[O:2][C@@:3]2([H:10])[C@@:...5.464328
2[C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H...5.270552
3[C:1]([O:2][C:3]([C@@:4]([C:5]([H:14])([H:15])...8.473006
4[C:1]([C:2]#[C:3][C:4]([C:5](=[O:6])[H:12])([H...5.579037
.........
95[C:1]([C:2]([C:3]([H:12])([H:13])[H:14])([C:4]...9.295665
96[O:1]=[C:2]([C@@:3]1([H:9])[C:4]([H:10])([H:11...7.753442
97[C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H...10.650215
98[C:1]1([H:8])([H:9])[C@@:2]2([H:10])[N:3]1[C:4...10.138945
99[C:1]([C@@:2]1([C:3]([C:4]([O:5][H:15])([H:13]...6.979934
\n", + "

100 rows × 2 columns

\n", + "
" + ], + "text/plain": [ + " smiles ea\n", + "0 [O:1]([C:2]([C:3]([C:4](=[O:5])[C:6]([O:7][H:1... 8.898934\n", + "1 [C:1]1([H:8])([H:9])[O:2][C@@:3]2([H:10])[C@@:... 5.464328\n", + "2 [C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H... 5.270552\n", + "3 [C:1]([O:2][C:3]([C@@:4]([C:5]([H:14])([H:15])... 8.473006\n", + "4 [C:1]([C:2]#[C:3][C:4]([C:5](=[O:6])[H:12])([H... 5.579037\n", + ".. ... ...\n", + "95 [C:1]([C:2]([C:3]([H:12])([H:13])[H:14])([C:4]... 9.295665\n", + "96 [O:1]=[C:2]([C@@:3]1([H:9])[C:4]([H:10])([H:11... 7.753442\n", + "97 [C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H... 10.650215\n", + "98 [C:1]1([H:8])([H:9])[C@@:2]2([H:10])[N:3]1[C:4... 10.138945\n", + "99 [C:1]([C@@:2]1([C:3]([C:4]([O:5][H:15])([H:13]... 6.979934\n", + "\n", + "[100 rows x 2 columns]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_input = pd.read_csv(input_path)\n", + "df_input" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load smiles and targets" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(array(['[O:1]([C:2]([C:3]([C:4](=[O:5])[C:6]([O:7][H:15])([H:13])[H:14])([H:11])[H:12])([H:9])[H:10])[H:8]>>[C:3](=[C:4]=[O:5])([H:11])[H:12].[C:6]([O:7][H:15])([H:8])([H:13])[H:14].[O:1]=[C:2]([H:9])[H:10]',\n", + " '[C:1]1([H:8])([H:9])[O:2][C@@:3]2([H:10])[C@@:4]3([H:11])[O:5][C@:6]1([H:12])[C@@:7]23[H:13]>>[C:1]1([H:8])([H:9])[O:2][C:3]([H:10])=[C:7]([H:13])[C@:6]1([O+:5]=[C-:4][H:11])[H:12]',\n", + " '[C:1]([C@@:2]1([H:11])[C@@:3]2([H:12])[C:4]([H:13])([H:14])[C:5]([H:15])=[C:6]([H:16])[C@@:7]12[H:17])([H:8])([H:9])[H:10]>>[C:1]([C@@:2]1([H:11])[C:3]([H:12])([H:13])[C:4]([H:14])=[C:5]([H:15])[C:6]([H:16])=[C:7]1[H:17])([H:8])([H:9])[H:10]',\n", + " '[C:1]([O:2][C:3]([C@@:4]([C:5]([H:14])([H:15])[H:16])([C:6]([O:7][H:19])([H:17])[H:18])[H:13])([H:11])[H:12])([H:8])([H:9])[H:10]>>[C-:1]([O+:2]=[C:3]([C@@:4]([C:5]([H:14])([H:15])[H:16])([C:6]([O:7][H:19])([H:17])[H:18])[H:13])[H:12])([H:8])[H:10].[H:9][H:11]',\n", + " '[C:1]([C:2]#[C:3][C:4]([C:5](=[O:6])[H:12])([H:10])[H:11])([H:7])([H:8])[H:9]>>[C:1]([C:2](=[C:3]=[C:4]([H:10])[H:11])[C:5](=[O:6])[H:12])([H:7])([H:8])[H:9]'],\n", + " dtype=object),\n", + " array([[8.8989335 ],\n", + " [5.46432769],\n", + " [5.27055228],\n", + " [8.47300569],\n", + " [5.57903696]]))" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "smis = df_input.loc[:, smiles_column].values\n", + "ys = df_input.loc[:, target_columns].values\n", + "\n", + "smis[:5], ys[:5]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get datapoints" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "all_data = [data.ReactionDatapoint.from_smi(smi, y) for smi, y in zip(smis, ys)]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Perform data splitting for training, validation, and testing" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "mols = [d.rct for d in all_data] # Can either split by reactants (.rct) or products (.pdt)\n", + "train_indices, val_indices, test_indices = data.make_split_indices(mols, \"random\", (0.8, 0.1, 0.1))\n", + "train_data, val_data, test_data = data.split_data_by_indices(\n", + " all_data, train_indices, val_indices, test_indices\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Defining the featurizer\n", + "\n", + "Reactions can be featurized using the ```CondensedGraphOfReactionFeaturizer``` (also labeled ```CGRFeaturizer```).\n", + "\n", + "\n", + "Use ```_mode``` keyword to set the mode by which a reaction should be featurized into a ```MolGraph```.\n", + "\n", + "Options are can be found with ```featurizers.RxnMode.keys```" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "REAC_PROD\n", + "REAC_PROD_BALANCE\n", + "REAC_DIFF\n", + "REAC_DIFF_BALANCE\n", + "PROD_DIFF\n", + "PROD_DIFF_BALANCE\n" + ] + } + ], + "source": [ + "for key in featurizers.RxnMode.keys():\n", + " print(key)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [], + "source": [ + "featurizer = featurizers.CondensedGraphOfReactionFeaturizer(mode_=\"PROD_DIFF\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get ReactionDatasets" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [], + "source": [ + "train_dset = data.ReactionDataset(train_data, featurizer)\n", + "scaler = train_dset.normalize_targets()\n", + "\n", + "val_dset = data.ReactionDataset(val_data, featurizer)\n", + "val_dset.normalize_targets(scaler)\n", + "test_dset = data.ReactionDataset(test_data, featurizer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get dataloaders" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "train_loader = data.build_dataloader(train_dset, num_workers=num_workers)\n", + "val_loader = data.build_dataloader(val_dset, num_workers=num_workers, shuffle=False)\n", + "test_loader = data.build_dataloader(test_dset, num_workers=num_workers, shuffle=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change Message-Passing Neural Network (MPNN) inputs here" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Message passing\n", + "\n", + "Message passing blocks must be given the shape of the featurizer's outputs.\n", + "\n", + "Options are `mp = nn.BondMessagePassing()` or `mp = nn.AtomMessagePassing()`" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "fdims = featurizer.shape # the dimensions of the featurizer, given as (atom_dims, bond_dims).\n", + "mp = nn.BondMessagePassing(*fdims)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Aggregation" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ClassRegistry {\n", + " 'mean': ,\n", + " 'sum': ,\n", + " 'norm': \n", + "}\n" + ] + } + ], + "source": [ + "print(nn.agg.AggregationRegistry)" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [], + "source": [ + "agg = nn.MeanAggregation()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Feed-Forward Network (FFN)" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ClassRegistry {\n", + " 'regression': ,\n", + " 'regression-mve': ,\n", + " 'regression-evidential': ,\n", + " 'classification': ,\n", + " 'classification-dirichlet': ,\n", + " 'multiclass': ,\n", + " 'multiclass-dirichlet': ,\n", + " 'spectral': \n", + "}\n" + ] + } + ], + "source": [ + "print(nn.PredictorRegistry)" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/Projects/chemprop_v2_dev/chemprop/chemprop/nn/transforms.py:21: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).\n", + " self.register_buffer(\"mean\", torch.tensor(mean, dtype=torch.float).unsqueeze(0))\n", + "/home/hwpang/Projects/chemprop_v2_dev/chemprop/chemprop/nn/transforms.py:22: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).\n", + " self.register_buffer(\"scale\", torch.tensor(scale, dtype=torch.float).unsqueeze(0))\n" + ] + } + ], + "source": [ + "output_transform = nn.UnscaleTransform.from_standard_scaler(scaler)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/utilities/parsing.py:199: Attribute 'output_transform' is an instance of `nn.Module` and is already saved during checkpointing. It is recommended to ignore them using `self.save_hyperparameters(ignore=['output_transform'])`.\n" + ] + } + ], + "source": [ + "ffn = nn.RegressionFFN(output_transform=output_transform)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Batch norm" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": {}, + "outputs": [], + "source": [ + "batch_norm = True" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Metrics" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ClassRegistry {\n", + " 'mae': ,\n", + " 'mse': ,\n", + " 'rmse': ,\n", + " 'bounded-mae': ,\n", + " 'bounded-mse': ,\n", + " 'bounded-rmse': ,\n", + " 'r2': ,\n", + " 'roc': ,\n", + " 'prc': ,\n", + " 'accuracy': ,\n", + " 'f1': ,\n", + " 'bce': ,\n", + " 'ce': ,\n", + " 'binary-mcc': ,\n", + " 'multiclass-mcc': ,\n", + " 'sid': ,\n", + " 'wasserstein': \n", + "}\n" + ] + } + ], + "source": [ + "print(nn.metrics.MetricRegistry)" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [], + "source": [ + "metric_list = [nn.metrics.RMSEMetric(), nn.metrics.MAEMetric()] \n", + "# Only the first metric is used for training and early stopping" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Construct MPNN" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "MPNN(\n", + " (message_passing): BondMessagePassing(\n", + " (W_i): Linear(in_features=134, out_features=300, bias=False)\n", + " (W_h): Linear(in_features=300, out_features=300, bias=False)\n", + " (W_o): Linear(in_features=406, out_features=300, bias=True)\n", + " (dropout): Dropout(p=0.0, inplace=False)\n", + " (tau): ReLU()\n", + " (V_d_transform): Identity()\n", + " (graph_transform): Identity()\n", + " )\n", + " (agg): MeanAggregation()\n", + " (bn): BatchNorm1d(300, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)\n", + " (predictor): RegressionFFN(\n", + " (ffn): MLP(\n", + " (0): Sequential(\n", + " (0): Linear(in_features=300, out_features=300, bias=True)\n", + " )\n", + " (1): Sequential(\n", + " (0): ReLU()\n", + " (1): Dropout(p=0.0, inplace=False)\n", + " (2): Linear(in_features=300, out_features=1, bias=True)\n", + " )\n", + " )\n", + " (criterion): MSELoss()\n", + " (output_transform): UnscaleTransform()\n", + " )\n", + " (X_d_transform): Identity()\n", + ")" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "mpnn = models.MPNN(mp, agg, ffn, batch_norm, metric_list)\n", + "mpnn" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Training and testing" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up trainer" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "GPU available: True (cuda), used: True\n", + "TPU available: False, using: 0 TPU cores\n", + "IPU available: False, using: 0 IPUs\n", + "HPU available: False, using: 0 HPUs\n" + ] + } + ], + "source": [ + "trainer = pl.Trainer(\n", + " logger=False,\n", + " enable_checkpointing=True, # Use `True` if you want to save model checkpoints. The checkpoints will be saved in the `checkpoints` folder.\n", + " enable_progress_bar=True,\n", + " accelerator=\"auto\",\n", + " devices=1,\n", + " max_epochs=20, # number of epochs to train for\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start training" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/fabric/plugins/environments/slurm.py:204: The `srun` command is available on your system but is not used. HINT: If your intention is to run Lightning on SLURM, prepend your python command with `srun` like so: srun python /home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/pyt ...\n", + "You are using a CUDA device ('NVIDIA GeForce RTX 4090') that has Tensor Cores. To properly utilize them, you should set `torch.set_float32_matmul_precision('medium' | 'high')` which will trade-off precision for performance. For more details, read https://pytorch.org/docs/stable/generated/torch.set_float32_matmul_precision.html#torch.set_float32_matmul_precision\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/callbacks/model_checkpoint.py:653: Checkpoint directory /home/hwpang/Projects/chemprop_v2_dev/chemprop/examples/checkpoints exists and is not empty.\n", + "LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0,1]\n", + "Loading `train_dataloader` to estimate number of stepping batches.\n", + "/home/hwpang/miniforge3/envs/chemprop_v2_dev/lib/python3.11/site-packages/lightning/pytorch/trainer/connectors/data_connector.py:441: The 'train_dataloader' does not have many workers which may be a bottleneck. Consider increasing the value of the `num_workers` argument` to `num_workers=63` in the `DataLoader` to improve performance.\n", + "\n", + " | Name | Type | Params\n", + "-------------------------------------------------------\n", + "0 | message_passing | BondMessagePassing | 252 K \n", + "1 | agg | MeanAggregation | 0 \n", + "2 | bn | BatchNorm1d | 600 \n", + "3 | predictor | RegressionFFN | 90.6 K\n", + "4 | X_d_transform | Identity | 0 \n", + " | other params | n/a | 1 \n", + "-------------------------------------------------------\n", + "343 K Trainable params\n", + "1 Non-trainable params\n", + "343 K Total params\n", + "1.374 Total estimated model params size (MB)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Sanity Checking DataLoader 0: 0%| | 0/1 [00:00