Spaces:
Paused
Paused
add chemprop files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +2 -4
- chemprop/__init__.py +5 -0
- chemprop/cli/common.py +183 -0
- chemprop/cli/conf.py +8 -0
- chemprop/cli/convert.py +55 -0
- chemprop/cli/fingerprint.py +197 -0
- chemprop/cli/hpopt.py +442 -0
- chemprop/cli/main.py +80 -0
- chemprop/cli/predict.py +315 -0
- chemprop/cli/train.py +1007 -0
- chemprop/cli/utils/__init__.py +31 -0
- chemprop/cli/utils/actions.py +28 -0
- chemprop/cli/utils/args.py +34 -0
- chemprop/cli/utils/command.py +24 -0
- chemprop/cli/utils/parsing.py +380 -0
- chemprop/cli/utils/utils.py +76 -0
- chemprop/conf.py +7 -0
- chemprop/data/__init__.py +34 -0
- chemprop/data/collate.py +120 -0
- chemprop/data/dataloader.py +69 -0
- chemprop/data/datapoints.py +169 -0
- chemprop/data/datasets.py +459 -0
- chemprop/data/molgraph.py +16 -0
- chemprop/data/samplers.py +66 -0
- chemprop/data/splitting.py +257 -0
- chemprop/exceptions.py +12 -0
- chemprop/featurizers/__init__.py +46 -0
- chemprop/featurizers/atom.py +222 -0
- chemprop/featurizers/base.py +30 -0
- chemprop/featurizers/bond.py +92 -0
- chemprop/featurizers/molecule.py +43 -0
- chemprop/featurizers/molgraph/__init__.py +13 -0
- chemprop/featurizers/molgraph/cache.py +89 -0
- chemprop/featurizers/molgraph/mixins.py +23 -0
- chemprop/featurizers/molgraph/molecule.py +95 -0
- chemprop/featurizers/molgraph/reaction.py +333 -0
- chemprop/models/__init__.py +5 -0
- chemprop/models/model.py +261 -0
- chemprop/models/multi.py +92 -0
- chemprop/models/utils.py +18 -0
- chemprop/nn/__init__.py +133 -0
- chemprop/nn/agg.py +132 -0
- chemprop/nn/ffn.py +63 -0
- chemprop/nn/hparams.py +38 -0
- chemprop/nn/loss.py +344 -0
- chemprop/nn/message_passing/__init__.py +10 -0
- chemprop/nn/message_passing/base.py +311 -0
- chemprop/nn/message_passing/multi.py +78 -0
- chemprop/nn/message_passing/proto.py +35 -0
- chemprop/nn/metrics.py +209 -0
Dockerfile
CHANGED
|
@@ -87,8 +87,6 @@ USER user
|
|
| 87 |
RUN --mount=target=requirements.txt,source=requirements.txt \
|
| 88 |
pip install --no-cache-dir --upgrade -r requirements.txt
|
| 89 |
|
| 90 |
-
# Pull chemprop from github
|
| 91 |
-
RUN git clone https://github.com/chemprop/chemprop.git $HOME/app/chemprop
|
| 92 |
|
| 93 |
# build an empty conda environment with appropriate Python version
|
| 94 |
RUN conda create --name chemprop_env python=3.11*
|
|
@@ -96,11 +94,11 @@ RUN conda create --name chemprop_env python=3.11*
|
|
| 96 |
SHELL ["conda", "run", "--no-capture-output", "-n", "chemprop_env", "/bin/bash", "-c"]
|
| 97 |
|
| 98 |
# Follow the installation instructions then clear the cache
|
| 99 |
-
ADD
|
| 100 |
ADD LICENSE.txt pyproject.toml README.md ./
|
| 101 |
RUN conda install pytorch cpuonly -c pytorch && \
|
| 102 |
conda clean --all --yes && \
|
| 103 |
-
python -m pip install .
|
| 104 |
python -m pip cache purge
|
| 105 |
|
| 106 |
|
|
|
|
| 87 |
RUN --mount=target=requirements.txt,source=requirements.txt \
|
| 88 |
pip install --no-cache-dir --upgrade -r requirements.txt
|
| 89 |
|
|
|
|
|
|
|
| 90 |
|
| 91 |
# build an empty conda environment with appropriate Python version
|
| 92 |
RUN conda create --name chemprop_env python=3.11*
|
|
|
|
| 94 |
SHELL ["conda", "run", "--no-capture-output", "-n", "chemprop_env", "/bin/bash", "-c"]
|
| 95 |
|
| 96 |
# Follow the installation instructions then clear the cache
|
| 97 |
+
ADD chemprop chemprop
|
| 98 |
ADD LICENSE.txt pyproject.toml README.md ./
|
| 99 |
RUN conda install pytorch cpuonly -c pytorch && \
|
| 100 |
conda clean --all --yes && \
|
| 101 |
+
python -m pip install . && \
|
| 102 |
python -m pip cache purge
|
| 103 |
|
| 104 |
|
chemprop/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from . import data, featurizers, models, nn, utils, conf, exceptions, schedulers
|
| 2 |
+
|
| 3 |
+
__all__ = ["data", "featurizers", "models", "nn", "utils", "conf", "exceptions", "schedulers"]
|
| 4 |
+
|
| 5 |
+
__version__ = "2.0.0"
|
chemprop/cli/common.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from argparse import ArgumentParser, Namespace, ArgumentError
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from chemprop.cli.utils import LookupAction
|
| 6 |
+
from chemprop.cli.utils.args import uppercase
|
| 7 |
+
from chemprop.featurizers import MoleculeFeaturizerRegistry, RxnMode, AtomFeatureMode
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def add_common_args(parser: ArgumentParser) -> ArgumentParser:
|
| 13 |
+
data_args = parser.add_argument_group("Shared input data args")
|
| 14 |
+
data_args.add_argument(
|
| 15 |
+
"-s",
|
| 16 |
+
"--smiles-columns",
|
| 17 |
+
nargs="+",
|
| 18 |
+
help="The column names in the input CSV containing SMILES strings. If unspecified, uses the the 0th column.",
|
| 19 |
+
)
|
| 20 |
+
data_args.add_argument(
|
| 21 |
+
"-r",
|
| 22 |
+
"--reaction-columns",
|
| 23 |
+
nargs="+",
|
| 24 |
+
help="The column names in the input CSV containing reaction SMILES in the format 'REACTANT>AGENT>PRODUCT', where 'AGENT' is optional.",
|
| 25 |
+
)
|
| 26 |
+
data_args.add_argument(
|
| 27 |
+
"--no-header-row",
|
| 28 |
+
action="store_true",
|
| 29 |
+
help="If specified, the first row in the input CSV will not be used as column names.",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
dataloader_args = parser.add_argument_group("Dataloader args")
|
| 33 |
+
dataloader_args.add_argument(
|
| 34 |
+
"-n",
|
| 35 |
+
"--num-workers",
|
| 36 |
+
type=int,
|
| 37 |
+
default=0,
|
| 38 |
+
help="""Number of workers for parallel data loading (0 means sequential).
|
| 39 |
+
Warning: setting num_workers>0 can cause hangs on Windows and MacOS.""",
|
| 40 |
+
)
|
| 41 |
+
dataloader_args.add_argument("-b", "--batch-size", type=int, default=64, help="Batch size.")
|
| 42 |
+
|
| 43 |
+
parser.add_argument(
|
| 44 |
+
"--accelerator", default="auto", help="Passed directly to the lightning Trainer()."
|
| 45 |
+
)
|
| 46 |
+
parser.add_argument(
|
| 47 |
+
"--devices",
|
| 48 |
+
default="auto",
|
| 49 |
+
help="Passed directly to the lightning Trainer(). If specifying multiple devices, must be a single string of comma separated devices, e.g. '1, 2'.",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
featurization_args = parser.add_argument_group("Featurization args")
|
| 53 |
+
featurization_args.add_argument(
|
| 54 |
+
"--rxn-mode",
|
| 55 |
+
"--reaction-mode",
|
| 56 |
+
type=uppercase,
|
| 57 |
+
default="REAC_DIFF",
|
| 58 |
+
choices=list(RxnMode.keys()),
|
| 59 |
+
help="""Choices for construction of atom and bond features for reactions (case insensitive):
|
| 60 |
+
- 'reac_prod': concatenates the reactants feature with the products feature.
|
| 61 |
+
- 'reac_diff': concatenates the reactants feature with the difference in features between reactants and products. (Default)
|
| 62 |
+
- 'prod_diff': concatenates the products feature with the difference in features between reactants and products.
|
| 63 |
+
- 'reac_prod_balance': concatenates the reactants feature with the products feature, balances imbalanced reactions.
|
| 64 |
+
- 'reac_diff_balance': concatenates the reactants feature with the difference in features between reactants and products, balances imbalanced reactions.
|
| 65 |
+
- 'prod_diff_balance': concatenates the products feature with the difference in features between reactants and products, balances imbalanced reactions.""",
|
| 66 |
+
)
|
| 67 |
+
# TODO: Update documenation for multi_hot_atom_featurizer_mode
|
| 68 |
+
featurization_args.add_argument(
|
| 69 |
+
"--multi-hot-atom-featurizer-mode",
|
| 70 |
+
type=uppercase,
|
| 71 |
+
default="V2",
|
| 72 |
+
choices=list(AtomFeatureMode.keys()),
|
| 73 |
+
help="""Choices for multi-hot atom featurization scheme. This will affect both non-reatction and reaction feturization (case insensitive):
|
| 74 |
+
- `V1`: Corresponds to the original configuration employed in the Chemprop V1.
|
| 75 |
+
- `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.
|
| 76 |
+
- `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.""",
|
| 77 |
+
)
|
| 78 |
+
featurization_args.add_argument(
|
| 79 |
+
"--keep-h",
|
| 80 |
+
action="store_true",
|
| 81 |
+
help="Whether hydrogens explicitly specified in input should be kept in the mol graph.",
|
| 82 |
+
)
|
| 83 |
+
featurization_args.add_argument(
|
| 84 |
+
"--add-h", action="store_true", help="Whether hydrogens should be added to the mol graph."
|
| 85 |
+
)
|
| 86 |
+
featurization_args.add_argument(
|
| 87 |
+
"--features-generators",
|
| 88 |
+
nargs="+",
|
| 89 |
+
action=LookupAction(MoleculeFeaturizerRegistry),
|
| 90 |
+
help="Method(s) of generating additional features.",
|
| 91 |
+
)
|
| 92 |
+
featurization_args.add_argument(
|
| 93 |
+
"--descriptors-path",
|
| 94 |
+
type=Path,
|
| 95 |
+
help="Path to extra descriptors to concatenate to learned representation.",
|
| 96 |
+
)
|
| 97 |
+
# TODO: Add in v2.1
|
| 98 |
+
# featurization_args.add_argument(
|
| 99 |
+
# "--phase-features-path",
|
| 100 |
+
# help="Path to features used to indicate the phase of the data in one-hot vector form. Used in spectra datatype.",
|
| 101 |
+
# )
|
| 102 |
+
featurization_args.add_argument(
|
| 103 |
+
"--no-descriptor-scaling", action="store_true", help="Turn off extra descriptor scaling."
|
| 104 |
+
)
|
| 105 |
+
featurization_args.add_argument(
|
| 106 |
+
"--no-atom-feature-scaling",
|
| 107 |
+
action="store_true",
|
| 108 |
+
help="Turn off extra atom feature scaling.",
|
| 109 |
+
)
|
| 110 |
+
featurization_args.add_argument(
|
| 111 |
+
"--no-atom-descriptor-scaling",
|
| 112 |
+
action="store_true",
|
| 113 |
+
help="Turn off extra atom descriptor scaling.",
|
| 114 |
+
)
|
| 115 |
+
featurization_args.add_argument(
|
| 116 |
+
"--no-bond-feature-scaling",
|
| 117 |
+
action="store_true",
|
| 118 |
+
help="Turn off extra bond feature scaling.",
|
| 119 |
+
)
|
| 120 |
+
featurization_args.add_argument(
|
| 121 |
+
"--atom-features-path",
|
| 122 |
+
nargs="+",
|
| 123 |
+
action="append",
|
| 124 |
+
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 [...]`.",
|
| 125 |
+
)
|
| 126 |
+
featurization_args.add_argument(
|
| 127 |
+
"--atom-descriptors-path",
|
| 128 |
+
nargs="+",
|
| 129 |
+
action="append",
|
| 130 |
+
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 [...]`.",
|
| 131 |
+
)
|
| 132 |
+
featurization_args.add_argument(
|
| 133 |
+
"--bond-features-path",
|
| 134 |
+
nargs="+",
|
| 135 |
+
action="append",
|
| 136 |
+
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 [...]`.",
|
| 137 |
+
)
|
| 138 |
+
# TODO: Add in v2.2
|
| 139 |
+
# parser.add_argument(
|
| 140 |
+
# "--constraints-path",
|
| 141 |
+
# help="Path to constraints applied to atomic/bond properties prediction.",
|
| 142 |
+
# )
|
| 143 |
+
|
| 144 |
+
return parser
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def process_common_args(args: Namespace) -> Namespace:
|
| 148 |
+
for key in ["atom_features_path", "atom_descriptors_path", "bond_features_path"]:
|
| 149 |
+
inds_paths = getattr(args, key)
|
| 150 |
+
|
| 151 |
+
if not inds_paths:
|
| 152 |
+
continue
|
| 153 |
+
|
| 154 |
+
ind_path_dict = {}
|
| 155 |
+
|
| 156 |
+
for ind_path in inds_paths:
|
| 157 |
+
if len(ind_path) > 2:
|
| 158 |
+
raise ArgumentError(
|
| 159 |
+
argument=None,
|
| 160 |
+
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).",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if len(ind_path) == 1:
|
| 164 |
+
ind = 0
|
| 165 |
+
path = ind_path[0]
|
| 166 |
+
else:
|
| 167 |
+
ind, path = ind_path
|
| 168 |
+
|
| 169 |
+
if ind_path_dict.get(int(ind), None):
|
| 170 |
+
raise ArgumentError(
|
| 171 |
+
argument=None,
|
| 172 |
+
message=f"Duplicate atom features/descriptors or bond features given for molecule index {ind}.",
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
ind_path_dict[int(ind)] = Path(path)
|
| 176 |
+
|
| 177 |
+
setattr(args, key, ind_path_dict)
|
| 178 |
+
|
| 179 |
+
return args
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def validate_common_args(args):
|
| 183 |
+
pass
|
chemprop/cli/conf.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
LOG_DIR = Path(os.getenv("CHEMPROP_LOG_DIR", "chemprop_logs"))
|
| 7 |
+
LOG_LEVELS = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
|
| 8 |
+
NOW = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
chemprop/cli/convert.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from argparse import ArgumentError, ArgumentParser, Namespace
|
| 2 |
+
import sys
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from chemprop.cli.utils import Subcommand
|
| 7 |
+
from chemprop.utils.v1_to_v2 import convert_model_file_v1_to_v2
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ConvertSubcommand(Subcommand):
|
| 13 |
+
COMMAND = "convert"
|
| 14 |
+
HELP = "convert a v1 model checkpoint (.pt) to a v2 model checkpoint (.ckpt)"
|
| 15 |
+
|
| 16 |
+
@classmethod
|
| 17 |
+
def add_args(cls, parser: ArgumentParser) -> ArgumentParser:
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"-i",
|
| 20 |
+
"--input-path",
|
| 21 |
+
required=True,
|
| 22 |
+
type=Path,
|
| 23 |
+
help="The path to a v1 model .pt checkpoint file.",
|
| 24 |
+
)
|
| 25 |
+
parser.add_argument(
|
| 26 |
+
"-o",
|
| 27 |
+
"--output-path",
|
| 28 |
+
type=Path,
|
| 29 |
+
help="The path to which the converted model will be saved. Defaults to 'CURRENT_DIRECTORY/STEM_OF_INPUT_v2.ckpt'",
|
| 30 |
+
)
|
| 31 |
+
return parser
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def func(cls, args: Namespace):
|
| 35 |
+
if args.output_path is None:
|
| 36 |
+
args.output_path = Path(args.input_path.stem + "_v2.ckpt")
|
| 37 |
+
if args.output_path.suffix != ".ckpt":
|
| 38 |
+
raise ArgumentError(
|
| 39 |
+
argument=None, message=f"Output must be a `.ckpt` file. Got {args.output_path}"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
logger.info(
|
| 43 |
+
f"Converting v1 model checkpoint '{args.input_path}' to v2 model checkpoint '{args.output_path}'..."
|
| 44 |
+
)
|
| 45 |
+
convert_model_file_v1_to_v2(args.input_path, args.output_path)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if __name__ == "__main__":
|
| 49 |
+
parser = ArgumentParser()
|
| 50 |
+
parser = ConvertSubcommand.add_args(parser)
|
| 51 |
+
|
| 52 |
+
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)
|
| 53 |
+
|
| 54 |
+
args = parser.parse_args()
|
| 55 |
+
ConvertSubcommand.func(args)
|
chemprop/cli/fingerprint.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
from argparse import ArgumentError, ArgumentParser, Namespace
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from chemprop import data
|
| 11 |
+
from chemprop.cli.common import add_common_args, process_common_args, validate_common_args
|
| 12 |
+
from chemprop.cli.utils import Subcommand, build_data_from_files, make_dataset
|
| 13 |
+
from chemprop.featurizers import MoleculeFeaturizerRegistry
|
| 14 |
+
from chemprop.models import load_model
|
| 15 |
+
from chemprop.nn.loss import LossFunctionRegistry
|
| 16 |
+
from chemprop.utils import Factory
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FingerprintSubcommand(Subcommand):
|
| 22 |
+
COMMAND = "fingerprint"
|
| 23 |
+
HELP = "use a pretrained chemprop model for to calculate learned representations"
|
| 24 |
+
|
| 25 |
+
@classmethod
|
| 26 |
+
def add_args(cls, parser: ArgumentParser) -> ArgumentParser:
|
| 27 |
+
parser = add_common_args(parser)
|
| 28 |
+
parser.add_argument(
|
| 29 |
+
"-i",
|
| 30 |
+
"--test-path",
|
| 31 |
+
required=True,
|
| 32 |
+
type=Path,
|
| 33 |
+
help="Path to an input CSV file containing SMILES.",
|
| 34 |
+
)
|
| 35 |
+
parser.add_argument(
|
| 36 |
+
"-o",
|
| 37 |
+
"--output",
|
| 38 |
+
"--preds-path",
|
| 39 |
+
type=Path,
|
| 40 |
+
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'.",
|
| 41 |
+
)
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--model-path",
|
| 44 |
+
required=True,
|
| 45 |
+
type=Path,
|
| 46 |
+
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.",
|
| 47 |
+
)
|
| 48 |
+
parser.add_argument(
|
| 49 |
+
"--ffn-block-index",
|
| 50 |
+
required=True,
|
| 51 |
+
type=int,
|
| 52 |
+
default=-1,
|
| 53 |
+
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.",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
return parser
|
| 57 |
+
|
| 58 |
+
@classmethod
|
| 59 |
+
def func(cls, args: Namespace):
|
| 60 |
+
args = process_common_args(args)
|
| 61 |
+
validate_common_args(args)
|
| 62 |
+
args = process_fingerprint_args(args)
|
| 63 |
+
main(args)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def process_fingerprint_args(args: Namespace) -> Namespace:
|
| 67 |
+
if args.test_path.suffix not in [".csv"]:
|
| 68 |
+
raise ArgumentError(
|
| 69 |
+
argument=None, message=f"Input data must be a CSV file. Got {args.test_path}"
|
| 70 |
+
)
|
| 71 |
+
if args.output is None:
|
| 72 |
+
args.output = args.test_path.parent / (args.test_path.stem + "_fps.csv")
|
| 73 |
+
if args.output.suffix not in [".csv", ".npz"]:
|
| 74 |
+
raise ArgumentError(
|
| 75 |
+
argument=None, message=f"Output must be a CSV or NPZ file. Got '{args.output}'."
|
| 76 |
+
)
|
| 77 |
+
return args
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def find_models(model_path: Path):
|
| 81 |
+
if model_path.suffix in [".ckpt", ".pt"]:
|
| 82 |
+
return [model_path]
|
| 83 |
+
elif model_path.is_dir():
|
| 84 |
+
return list(model_path.rglob("*.ckpt")) + list(model_path.rglob("*.pt"))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def make_fingerprint_for_model(
|
| 88 |
+
args: Namespace, model_path: Path, multicomponent: bool, output_path: Path
|
| 89 |
+
):
|
| 90 |
+
model = load_model(model_path, multicomponent)
|
| 91 |
+
model.eval()
|
| 92 |
+
|
| 93 |
+
bounded = any(
|
| 94 |
+
isinstance(model.criterion, LossFunctionRegistry[loss_function])
|
| 95 |
+
for loss_function in LossFunctionRegistry.keys()
|
| 96 |
+
if "bounded" in loss_function
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
format_kwargs = dict(
|
| 100 |
+
no_header_row=args.no_header_row,
|
| 101 |
+
smiles_cols=args.smiles_columns,
|
| 102 |
+
rxn_cols=args.reaction_columns,
|
| 103 |
+
target_cols=None,
|
| 104 |
+
ignore_cols=None,
|
| 105 |
+
splits_col=None,
|
| 106 |
+
weight_col=None,
|
| 107 |
+
bounded=bounded,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
if args.features_generators is not None:
|
| 111 |
+
# TODO: MorganFeaturizers take radius, length, and include_chirality as arguements. Should we expose these through the CLI?
|
| 112 |
+
features_generators = [
|
| 113 |
+
Factory.build(MoleculeFeaturizerRegistry[features_generator])
|
| 114 |
+
for features_generator in args.features_generators
|
| 115 |
+
]
|
| 116 |
+
else:
|
| 117 |
+
features_generators = None
|
| 118 |
+
|
| 119 |
+
featurization_kwargs = dict(
|
| 120 |
+
features_generators=features_generators, keep_h=args.keep_h, add_h=args.add_h
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
test_data = build_data_from_files(
|
| 124 |
+
args.test_path,
|
| 125 |
+
**format_kwargs,
|
| 126 |
+
p_descriptors=args.descriptors_path,
|
| 127 |
+
p_atom_feats=args.atom_features_path,
|
| 128 |
+
p_bond_feats=args.bond_features_path,
|
| 129 |
+
p_atom_descs=args.atom_descriptors_path,
|
| 130 |
+
**featurization_kwargs,
|
| 131 |
+
)
|
| 132 |
+
logger.info(f"test size: {len(test_data[0])}")
|
| 133 |
+
test_dsets = [
|
| 134 |
+
make_dataset(d, args.rxn_mode, args.multi_hot_atom_featurizer_mode) for d in test_data
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
if multicomponent:
|
| 138 |
+
test_dset = data.MulticomponentDataset(test_dsets)
|
| 139 |
+
else:
|
| 140 |
+
test_dset = test_dsets[0]
|
| 141 |
+
|
| 142 |
+
test_loader = data.build_dataloader(test_dset, args.batch_size, args.num_workers, shuffle=False)
|
| 143 |
+
|
| 144 |
+
logger.info(model)
|
| 145 |
+
|
| 146 |
+
with torch.no_grad():
|
| 147 |
+
if multicomponent:
|
| 148 |
+
encodings = [
|
| 149 |
+
model.encoding(batch.bmgs, batch.V_ds, batch.X_d, args.ffn_block_index)
|
| 150 |
+
for batch in test_loader
|
| 151 |
+
]
|
| 152 |
+
else:
|
| 153 |
+
encodings = [
|
| 154 |
+
model.encoding(batch.bmg, batch.V_d, batch.X_d, args.ffn_block_index)
|
| 155 |
+
for batch in test_loader
|
| 156 |
+
]
|
| 157 |
+
H = torch.cat(encodings, 0).numpy()
|
| 158 |
+
|
| 159 |
+
if output_path.suffix in [".npz"]:
|
| 160 |
+
np.savez(output_path, H=H)
|
| 161 |
+
elif output_path.suffix == ".csv":
|
| 162 |
+
fingerprint_columns = [f"fp_{i}" for i in range(H.shape[1])]
|
| 163 |
+
df_fingerprints = pd.DataFrame(H, columns=fingerprint_columns)
|
| 164 |
+
df_fingerprints.to_csv(output_path, index=False)
|
| 165 |
+
else:
|
| 166 |
+
raise ArgumentError(
|
| 167 |
+
argument=None, message=f"Output must be a CSV or npz file. Got {args.output}."
|
| 168 |
+
)
|
| 169 |
+
logger.info(f"Fingerprints saved to '{output_path}'")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def main(args):
|
| 173 |
+
match (args.smiles_columns, args.reaction_columns):
|
| 174 |
+
case [None, None]:
|
| 175 |
+
n_components = 1
|
| 176 |
+
case [_, None]:
|
| 177 |
+
n_components = len(args.smiles_columns)
|
| 178 |
+
case [None, _]:
|
| 179 |
+
n_components = len(args.reaction_columns)
|
| 180 |
+
case _:
|
| 181 |
+
n_components = len(args.smiles_columns) + len(args.reaction_columns)
|
| 182 |
+
|
| 183 |
+
multicomponent = n_components > 1
|
| 184 |
+
|
| 185 |
+
for i, model_path in enumerate(find_models(args.model_path)):
|
| 186 |
+
logger.info(f"Fingerprints with model at '{model_path}'")
|
| 187 |
+
output_path = args.output.parent / f"{args.output.stem}_{i}{args.output.suffix}"
|
| 188 |
+
make_fingerprint_for_model(args, model_path, multicomponent, output_path)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
if __name__ == "__main__":
|
| 192 |
+
parser = ArgumentParser()
|
| 193 |
+
parser = FingerprintSubcommand.add_args(parser)
|
| 194 |
+
|
| 195 |
+
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)
|
| 196 |
+
args = parser.parse_args()
|
| 197 |
+
args = FingerprintSubcommand.func(args)
|
chemprop/cli/hpopt.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from argparse import ArgumentParser, Namespace
|
| 5 |
+
from copy import deepcopy
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import torch
|
| 8 |
+
from lightning import pytorch as pl
|
| 9 |
+
from lightning.pytorch.callbacks import EarlyStopping
|
| 10 |
+
|
| 11 |
+
from chemprop.cli.common import add_common_args, process_common_args, validate_common_args
|
| 12 |
+
from chemprop.cli.train import (
|
| 13 |
+
add_train_args,
|
| 14 |
+
build_datasets,
|
| 15 |
+
build_model,
|
| 16 |
+
build_splits,
|
| 17 |
+
normalize_inputs,
|
| 18 |
+
process_train_args,
|
| 19 |
+
validate_train_args,
|
| 20 |
+
)
|
| 21 |
+
from chemprop.cli.utils.command import Subcommand
|
| 22 |
+
from chemprop.data import build_dataloader
|
| 23 |
+
from chemprop.featurizers import MoleculeFeaturizerRegistry
|
| 24 |
+
from chemprop.nn import AggregationRegistry
|
| 25 |
+
from chemprop.nn.transforms import UnscaleTransform
|
| 26 |
+
from chemprop.nn.utils import Activation
|
| 27 |
+
from chemprop.utils import Factory
|
| 28 |
+
|
| 29 |
+
NO_RAY = False
|
| 30 |
+
DEFAULT_SEARCH_SPACE = {}
|
| 31 |
+
try:
|
| 32 |
+
import ray
|
| 33 |
+
from ray import tune
|
| 34 |
+
from ray.train import CheckpointConfig, RunConfig, ScalingConfig
|
| 35 |
+
from ray.train.lightning import (
|
| 36 |
+
RayDDPStrategy,
|
| 37 |
+
RayLightningEnvironment,
|
| 38 |
+
RayTrainReportCallback,
|
| 39 |
+
prepare_trainer,
|
| 40 |
+
)
|
| 41 |
+
from ray.train.torch import TorchTrainer
|
| 42 |
+
from ray.tune.schedulers import ASHAScheduler
|
| 43 |
+
|
| 44 |
+
DEFAULT_SEARCH_SPACE = {
|
| 45 |
+
"activation": tune.choice(categories=list(Activation.keys())),
|
| 46 |
+
"aggregation": tune.choice(categories=list(AggregationRegistry.keys())),
|
| 47 |
+
"aggregation_norm": tune.quniform(lower=1, upper=200, q=1),
|
| 48 |
+
"batch_size": tune.choice([16, 32, 64, 128, 256]),
|
| 49 |
+
"depth": tune.qrandint(lower=2, upper=6, q=1),
|
| 50 |
+
"dropout": tune.choice([tune.choice([0.0]), tune.quniform(lower=0.05, upper=0.4, q=0.05)]),
|
| 51 |
+
"ffn_hidden_dim": tune.qrandint(lower=300, upper=2400, q=100),
|
| 52 |
+
"ffn_num_layers": tune.qrandint(lower=1, upper=3, q=1),
|
| 53 |
+
"final_lr_ratio": tune.loguniform(lower=1e-2, upper=1),
|
| 54 |
+
"message_hidden_dim": tune.qrandint(lower=300, upper=2400, q=100),
|
| 55 |
+
"init_lr_ratio": tune.loguniform(lower=1e-2, upper=1),
|
| 56 |
+
"max_lr": tune.loguniform(lower=1e-4, upper=1e-2),
|
| 57 |
+
"warmup_epochs": None,
|
| 58 |
+
}
|
| 59 |
+
except ImportError:
|
| 60 |
+
NO_RAY = True
|
| 61 |
+
|
| 62 |
+
NO_HYPEROPT = False
|
| 63 |
+
try:
|
| 64 |
+
from ray.tune.search.hyperopt import HyperOptSearch
|
| 65 |
+
except ImportError:
|
| 66 |
+
NO_HYPEROPT = True
|
| 67 |
+
|
| 68 |
+
# NO_OPTUNA = False
|
| 69 |
+
# try:
|
| 70 |
+
# from ray.tune.search.optuna import OptunaSearch
|
| 71 |
+
# except ImportError:
|
| 72 |
+
# NO_OPTUNA = True
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
logger = logging.getLogger(__name__)
|
| 76 |
+
|
| 77 |
+
SEARCH_SPACE = DEFAULT_SEARCH_SPACE
|
| 78 |
+
|
| 79 |
+
SEARCH_PARAM_KEYWORDS_MAP = {
|
| 80 |
+
"basic": ["depth", "ffn_num_layers", "dropout", "ffn_hidden_dim", "message_hidden_dim"],
|
| 81 |
+
"learning_rate": ["max_lr", "init_lr_ratio", "final_lr_ratio", "warmup_epochs"],
|
| 82 |
+
"all": list(DEFAULT_SEARCH_SPACE.keys()),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class HpoptSubcommand(Subcommand):
|
| 87 |
+
COMMAND = "hpopt"
|
| 88 |
+
HELP = "perform hyperparameter optimization on the given task"
|
| 89 |
+
|
| 90 |
+
@classmethod
|
| 91 |
+
def add_args(cls, parser: ArgumentParser) -> ArgumentParser:
|
| 92 |
+
parser = add_common_args(parser)
|
| 93 |
+
parser = add_train_args(parser)
|
| 94 |
+
return add_hpopt_args(parser)
|
| 95 |
+
|
| 96 |
+
@classmethod
|
| 97 |
+
def func(cls, args: Namespace):
|
| 98 |
+
args = process_common_args(args)
|
| 99 |
+
args = process_train_args(args)
|
| 100 |
+
args = process_hpopt_args(args)
|
| 101 |
+
validate_common_args(args)
|
| 102 |
+
validate_train_args(args)
|
| 103 |
+
main(args)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def add_hpopt_args(parser: ArgumentParser) -> ArgumentParser:
|
| 107 |
+
hpopt_args = parser.add_argument_group("Chemprop hyperparameter optimization arguments")
|
| 108 |
+
|
| 109 |
+
hpopt_args.add_argument(
|
| 110 |
+
"--search-parameter-keywords",
|
| 111 |
+
type=str,
|
| 112 |
+
nargs="+",
|
| 113 |
+
default=["basic"],
|
| 114 |
+
help=f"""The model parameters over which to search for an optimal hyperparameter configuration.
|
| 115 |
+
Some options are bundles of parameters or otherwise special parameter operations.
|
| 116 |
+
|
| 117 |
+
Special keywords:
|
| 118 |
+
basic - the default set of hyperparameters for search: depth, ffn_num_layers, dropout, message_hidden_dim, and ffn_hidden_dim.
|
| 119 |
+
learning_rate - search for max_lr, init_lr_ratio, final_lr_ratio, and warmup_epochs. The search for init_lr and final_lr values
|
| 120 |
+
are defined as fractions of the max_lr value. The search for warmup_epochs is as a fraction of the total epochs used.
|
| 121 |
+
all - include search for all 13 inidividual keyword options
|
| 122 |
+
|
| 123 |
+
Individual supported parameters:
|
| 124 |
+
{list(DEFAULT_SEARCH_SPACE.keys())}
|
| 125 |
+
""",
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
hpopt_args.add_argument(
|
| 129 |
+
"--hpopt-save-dir",
|
| 130 |
+
type=Path,
|
| 131 |
+
help="Directory to save the hyperparameter optimization results",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
raytune_args = parser.add_argument_group("Ray Tune arguments")
|
| 135 |
+
|
| 136 |
+
raytune_args.add_argument(
|
| 137 |
+
"--raytune-num-samples",
|
| 138 |
+
type=int,
|
| 139 |
+
default=10,
|
| 140 |
+
help="Passed directly to Ray Tune TuneConfig to control number of trials to run",
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
raytune_args.add_argument(
|
| 144 |
+
"--raytune-search-algorithm",
|
| 145 |
+
choices=["random", "hyperopt"], # , "optuna"],
|
| 146 |
+
default="hyperopt",
|
| 147 |
+
help="Passed to Ray Tune TuneConfig to control search algorithm",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
raytune_args.add_argument(
|
| 151 |
+
"--raytune-num-workers",
|
| 152 |
+
type=int,
|
| 153 |
+
default=1,
|
| 154 |
+
help="Passed directly to Ray Tune ScalingConfig to control number of workers to use",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
raytune_args.add_argument(
|
| 158 |
+
"--raytune-use-gpu",
|
| 159 |
+
action="store_true",
|
| 160 |
+
help="Passed directly to Ray Tune ScalingConfig to control whether to use GPUs",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
raytune_args.add_argument(
|
| 164 |
+
"--raytune-num-checkpoints-to-keep",
|
| 165 |
+
type=int,
|
| 166 |
+
default=1,
|
| 167 |
+
help="Passed directly to Ray Tune CheckpointConfig to control number of checkpoints to keep",
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
raytune_args.add_argument(
|
| 171 |
+
"--raytune-grace-period",
|
| 172 |
+
type=int,
|
| 173 |
+
default=10,
|
| 174 |
+
help="Passed directly to Ray Tune ASHAScheduler to control grace period",
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
raytune_args.add_argument(
|
| 178 |
+
"--raytune-reduction-factor",
|
| 179 |
+
type=int,
|
| 180 |
+
default=2,
|
| 181 |
+
help="Passed directly to Ray Tune ASHAScheduler to control reduction factor",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
hyperopt_args = parser.add_argument_group("Hyperopt arguments")
|
| 185 |
+
|
| 186 |
+
hyperopt_args.add_argument(
|
| 187 |
+
"--hyperopt-n-initial-points",
|
| 188 |
+
type=int,
|
| 189 |
+
default=20,
|
| 190 |
+
help="Passed directly to HyperOptSearch to control number of initial points to sample",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
hyperopt_args.add_argument(
|
| 194 |
+
"--hyperopt-random-state-seed",
|
| 195 |
+
type=int,
|
| 196 |
+
default=None,
|
| 197 |
+
help="Passed directly to HyperOptSearch to control random state seed",
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return parser
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def process_hpopt_args(args: Namespace) -> Namespace:
|
| 204 |
+
if args.hpopt_save_dir is None:
|
| 205 |
+
args.hpopt_save_dir = Path(f"chemprop_hpopt/{args.data_path.stem}")
|
| 206 |
+
|
| 207 |
+
args.hpopt_save_dir.mkdir(exist_ok=True, parents=True)
|
| 208 |
+
|
| 209 |
+
search_parameters = set()
|
| 210 |
+
|
| 211 |
+
for keyword in args.search_parameter_keywords:
|
| 212 |
+
if keyword not in SEARCH_PARAM_KEYWORDS_MAP and keyword not in SEARCH_SPACE:
|
| 213 |
+
raise ValueError(
|
| 214 |
+
f"Search parameter keyword: {keyword} not in available options: {list(SEARCH_PARAM_KEYWORDS_MAP.keys()) + list(SEARCH_SPACE.keys())}."
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
search_parameters.update(
|
| 218 |
+
SEARCH_PARAM_KEYWORDS_MAP[keyword]
|
| 219 |
+
if keyword in SEARCH_PARAM_KEYWORDS_MAP
|
| 220 |
+
else [keyword]
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
args.search_parameter_keywords = list(search_parameters)
|
| 224 |
+
|
| 225 |
+
return args
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def build_search_space(search_parameters: list[str], train_epochs: int) -> dict:
|
| 229 |
+
if "warmup_epochs" in search_parameters and SEARCH_SPACE.get("warmup_epochs", None) is None:
|
| 230 |
+
SEARCH_SPACE["warmup_epochs"] = tune.qrandint(lower=1, upper=train_epochs // 2, q=1)
|
| 231 |
+
|
| 232 |
+
return {param: SEARCH_SPACE[param] for param in search_parameters}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def update_args_with_config(args: Namespace, config: dict) -> Namespace:
|
| 236 |
+
args = deepcopy(args)
|
| 237 |
+
|
| 238 |
+
for key, value in config.items():
|
| 239 |
+
match key:
|
| 240 |
+
case "final_lr_ratio":
|
| 241 |
+
setattr(args, "final_lr", value * args.max_lr)
|
| 242 |
+
|
| 243 |
+
case "init_lr_ratio":
|
| 244 |
+
setattr(args, "init_lr", value * args.max_lr)
|
| 245 |
+
|
| 246 |
+
case _:
|
| 247 |
+
assert key in args, f"Key: {key} not found in args."
|
| 248 |
+
setattr(args, key, value)
|
| 249 |
+
|
| 250 |
+
return args
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def train_model(config, args, train_dset, val_dset, logger, output_transform, input_transforms):
|
| 254 |
+
update_args_with_config(args, config)
|
| 255 |
+
|
| 256 |
+
train_loader = build_dataloader(
|
| 257 |
+
train_dset, args.batch_size, args.num_workers, seed=args.data_seed
|
| 258 |
+
)
|
| 259 |
+
val_loader = build_dataloader(val_dset, args.batch_size, args.num_workers, shuffle=False)
|
| 260 |
+
|
| 261 |
+
seed = args.pytorch_seed if args.pytorch_seed is not None else torch.seed()
|
| 262 |
+
|
| 263 |
+
torch.manual_seed(seed)
|
| 264 |
+
|
| 265 |
+
model = build_model(args, train_loader.dataset, output_transform, input_transforms)
|
| 266 |
+
logger.info(model)
|
| 267 |
+
|
| 268 |
+
monitor_mode = "min" if model.metrics[0].minimize else "max"
|
| 269 |
+
logger.debug(f"Evaluation metric: '{model.metrics[0].alias}', mode: '{monitor_mode}'")
|
| 270 |
+
|
| 271 |
+
patience = args.patience if args.patience is not None else args.epochs
|
| 272 |
+
early_stopping = EarlyStopping("val_loss", patience=patience, mode=monitor_mode)
|
| 273 |
+
|
| 274 |
+
trainer = pl.Trainer(
|
| 275 |
+
accelerator=args.accelerator,
|
| 276 |
+
devices=args.devices,
|
| 277 |
+
max_epochs=args.epochs,
|
| 278 |
+
gradient_clip_val=args.grad_clip,
|
| 279 |
+
strategy=RayDDPStrategy(find_unused_parameters=True),
|
| 280 |
+
callbacks=[RayTrainReportCallback(), early_stopping],
|
| 281 |
+
plugins=[RayLightningEnvironment()],
|
| 282 |
+
deterministic=args.pytorch_seed is not None,
|
| 283 |
+
)
|
| 284 |
+
trainer = prepare_trainer(trainer)
|
| 285 |
+
trainer.fit(model, train_loader, val_loader)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def tune_model(
|
| 289 |
+
args, train_dset, val_dset, logger, monitor_mode, output_transform, input_transforms
|
| 290 |
+
):
|
| 291 |
+
scheduler = ASHAScheduler(
|
| 292 |
+
max_t=args.epochs,
|
| 293 |
+
grace_period=min(args.raytune_grace_period, args.epochs),
|
| 294 |
+
reduction_factor=args.raytune_reduction_factor,
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
scaling_config = ScalingConfig(
|
| 298 |
+
num_workers=args.raytune_num_workers, use_gpu=args.raytune_use_gpu
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
checkpoint_config = CheckpointConfig(
|
| 302 |
+
num_to_keep=args.raytune_num_checkpoints_to_keep,
|
| 303 |
+
checkpoint_score_attribute="val_loss",
|
| 304 |
+
checkpoint_score_order=monitor_mode,
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
run_config = RunConfig(
|
| 308 |
+
checkpoint_config=checkpoint_config,
|
| 309 |
+
storage_path=args.hpopt_save_dir.absolute() / "ray_results",
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
ray_trainer = TorchTrainer(
|
| 313 |
+
lambda config: train_model(
|
| 314 |
+
config, args, train_dset, val_dset, logger, output_transform, input_transforms
|
| 315 |
+
),
|
| 316 |
+
scaling_config=scaling_config,
|
| 317 |
+
run_config=run_config,
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
match args.raytune_search_algorithm:
|
| 321 |
+
case "random":
|
| 322 |
+
search_alg = None
|
| 323 |
+
case "hyperopt":
|
| 324 |
+
if NO_HYPEROPT:
|
| 325 |
+
raise ImportError(
|
| 326 |
+
"HyperOptSearch requires hyperopt to be installed. Use 'pip -U install hyperopt' to install."
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
search_alg = HyperOptSearch(
|
| 330 |
+
n_initial_points=args.hyperopt_n_initial_points,
|
| 331 |
+
random_state_seed=args.hyperopt_random_state_seed,
|
| 332 |
+
)
|
| 333 |
+
# case "optuna":
|
| 334 |
+
# if NO_OPTUNA:
|
| 335 |
+
# raise ImportError(
|
| 336 |
+
# "OptunaSearch requires optuna to be installed. Use 'pip -U install optuna' to install."
|
| 337 |
+
# )
|
| 338 |
+
|
| 339 |
+
# search_alg = OptunaSearch()
|
| 340 |
+
|
| 341 |
+
tune_config = tune.TuneConfig(
|
| 342 |
+
metric="val_loss",
|
| 343 |
+
mode=monitor_mode,
|
| 344 |
+
num_samples=args.raytune_num_samples,
|
| 345 |
+
scheduler=scheduler,
|
| 346 |
+
search_alg=search_alg,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
tuner = tune.Tuner(
|
| 350 |
+
ray_trainer,
|
| 351 |
+
param_space={
|
| 352 |
+
"train_loop_config": build_search_space(args.search_parameter_keywords, args.epochs)
|
| 353 |
+
},
|
| 354 |
+
tune_config=tune_config,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
return tuner.fit()
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def main(args: Namespace):
|
| 361 |
+
if NO_RAY:
|
| 362 |
+
raise ImportError(
|
| 363 |
+
"Ray Tune requires ray to be installed. Use 'pip -U install ray[tune]' to install."
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
format_kwargs = dict(
|
| 367 |
+
no_header_row=args.no_header_row,
|
| 368 |
+
smiles_cols=args.smiles_columns,
|
| 369 |
+
rxn_cols=args.reaction_columns,
|
| 370 |
+
target_cols=args.target_columns,
|
| 371 |
+
ignore_cols=args.ignore_columns,
|
| 372 |
+
splits_col=args.splits_column,
|
| 373 |
+
weight_col=args.weight_column,
|
| 374 |
+
bounded=args.loss_function is not None and "bounded" in args.loss_function,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
if args.features_generators is not None:
|
| 378 |
+
# TODO: MorganFeaturizers take radius, length, and include_chirality as arguements. Should we expose these through the CLI?
|
| 379 |
+
features_generators = [
|
| 380 |
+
Factory.build(MoleculeFeaturizerRegistry[features_generator])
|
| 381 |
+
for features_generator in args.features_generators
|
| 382 |
+
]
|
| 383 |
+
else:
|
| 384 |
+
features_generators = None
|
| 385 |
+
|
| 386 |
+
featurization_kwargs = dict(
|
| 387 |
+
features_generators=features_generators, keep_h=args.keep_h, add_h=args.add_h
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
train_data, val_data, test_data = build_splits(args, format_kwargs, featurization_kwargs)
|
| 391 |
+
train_dset, val_dset, test_dset = build_datasets(args, train_data[0], val_data[0], test_data[0])
|
| 392 |
+
|
| 393 |
+
input_transforms = normalize_inputs(train_dset, val_dset, args)
|
| 394 |
+
|
| 395 |
+
if "regression" in args.task_type:
|
| 396 |
+
output_scaler = train_dset.normalize_targets()
|
| 397 |
+
val_dset.normalize_targets(output_scaler)
|
| 398 |
+
logger.info(f"Train data: mean = {output_scaler.mean_} | std = {output_scaler.scale_}")
|
| 399 |
+
output_transform = UnscaleTransform.from_standard_scaler(output_scaler)
|
| 400 |
+
else:
|
| 401 |
+
output_transform = None
|
| 402 |
+
|
| 403 |
+
train_loader = build_dataloader(
|
| 404 |
+
train_dset, args.batch_size, args.num_workers, seed=args.data_seed
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
model = build_model(args, train_loader.dataset, output_transform, input_transforms)
|
| 408 |
+
monitor_mode = "min" if model.metrics[0].minimize else "max"
|
| 409 |
+
|
| 410 |
+
results = tune_model(
|
| 411 |
+
args, train_dset, val_dset, logger, monitor_mode, output_transform, input_transforms
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
best_result = results.get_best_result()
|
| 415 |
+
best_config = best_result.config
|
| 416 |
+
best_checkpoint = best_result.checkpoint # Get best trial's best checkpoint
|
| 417 |
+
|
| 418 |
+
logger.info(f"Saving best hyperparameter parameters: {best_config}")
|
| 419 |
+
|
| 420 |
+
with open(args.hpopt_save_dir / "best_params.json", "w") as f:
|
| 421 |
+
json.dump(best_config, f, indent=4)
|
| 422 |
+
|
| 423 |
+
logger.info(f"Saving best hyperparameter configuration checkpoint: {best_checkpoint}")
|
| 424 |
+
|
| 425 |
+
torch.save(best_checkpoint, args.hpopt_save_dir / "best_checkpoint.ckpt")
|
| 426 |
+
|
| 427 |
+
result_df = results.get_dataframe()
|
| 428 |
+
|
| 429 |
+
logger.info(f"Saving hyperparameter optimization results: {result_df}")
|
| 430 |
+
|
| 431 |
+
result_df.to_csv(args.hpopt_save_dir / "all_progress.csv", index=False)
|
| 432 |
+
|
| 433 |
+
ray.shutdown()
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
if __name__ == "__main__":
|
| 437 |
+
parser = ArgumentParser()
|
| 438 |
+
parser = HpoptSubcommand.add_args(parser)
|
| 439 |
+
|
| 440 |
+
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)
|
| 441 |
+
args = parser.parse_args()
|
| 442 |
+
HpoptSubcommand.func(args)
|
chemprop/cli/main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from configargparse import ArgumentParser
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from chemprop.cli.train import TrainSubcommand
|
| 7 |
+
from chemprop.cli.predict import PredictSubcommand
|
| 8 |
+
from chemprop.cli.convert import ConvertSubcommand
|
| 9 |
+
from chemprop.cli.fingerprint import FingerprintSubcommand
|
| 10 |
+
from chemprop.cli.hpopt import HpoptSubcommand
|
| 11 |
+
|
| 12 |
+
from chemprop.cli.utils import pop_attr
|
| 13 |
+
from chemprop.cli.conf import LOG_DIR, LOG_LEVELS, NOW
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
SUBCOMMANDS = [
|
| 18 |
+
TrainSubcommand,
|
| 19 |
+
PredictSubcommand,
|
| 20 |
+
ConvertSubcommand,
|
| 21 |
+
FingerprintSubcommand,
|
| 22 |
+
HpoptSubcommand,
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def construct_parser():
|
| 27 |
+
parser = ArgumentParser()
|
| 28 |
+
subparsers = parser.add_subparsers(title="mode", dest="mode", required=True)
|
| 29 |
+
|
| 30 |
+
parent = ArgumentParser(add_help=False)
|
| 31 |
+
parent.add_argument(
|
| 32 |
+
"--logfile",
|
| 33 |
+
"--log",
|
| 34 |
+
nargs="?",
|
| 35 |
+
const="default",
|
| 36 |
+
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}.",
|
| 37 |
+
)
|
| 38 |
+
parent.add_argument(
|
| 39 |
+
"-v",
|
| 40 |
+
"--verbose",
|
| 41 |
+
action="count",
|
| 42 |
+
default=0,
|
| 43 |
+
help="The verbosity level, specify the flag multiple times to increase verbosity.",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
parents = [parent]
|
| 47 |
+
for subcommand in SUBCOMMANDS:
|
| 48 |
+
subcommand.add(subparsers, parents)
|
| 49 |
+
|
| 50 |
+
return parser
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def main():
|
| 54 |
+
parser = construct_parser()
|
| 55 |
+
args = parser.parse_args()
|
| 56 |
+
logfile, verbose, mode, func = (
|
| 57 |
+
pop_attr(args, attr) for attr in ["logfile", "verbose", "mode", "func"]
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
match logfile:
|
| 61 |
+
case None:
|
| 62 |
+
handler = logging.StreamHandler(sys.stderr)
|
| 63 |
+
case "default":
|
| 64 |
+
(LOG_DIR / mode).mkdir(parents=True, exist_ok=True)
|
| 65 |
+
handler = logging.FileHandler(str(LOG_DIR / mode / f"{NOW}.log"))
|
| 66 |
+
case _:
|
| 67 |
+
Path(logfile).parent.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
handler = logging.FileHandler(logfile)
|
| 69 |
+
|
| 70 |
+
logging.basicConfig(
|
| 71 |
+
handlers=[handler],
|
| 72 |
+
format="%(asctime)s - %(levelname)s:%(name)s - %(message)s",
|
| 73 |
+
level=LOG_LEVELS[min(verbose, len(LOG_LEVELS) - 1)],
|
| 74 |
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
| 75 |
+
force=True,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
logger.info(f"Running in mode '{mode}' with args: {vars(args)}")
|
| 79 |
+
|
| 80 |
+
func(args)
|
chemprop/cli/predict.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from argparse import ArgumentError, ArgumentParser, Namespace
|
| 2 |
+
import logging
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import sys
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from lightning import pytorch as pl
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from chemprop import data
|
| 11 |
+
from chemprop.nn.loss import LossFunctionRegistry
|
| 12 |
+
from chemprop.nn.predictors import MulticlassClassificationFFN
|
| 13 |
+
from chemprop.models import load_model
|
| 14 |
+
|
| 15 |
+
from chemprop.cli.utils import Subcommand, build_data_from_files, make_dataset
|
| 16 |
+
from chemprop.cli.common import add_common_args, process_common_args, validate_common_args
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PredictSubcommand(Subcommand):
|
| 23 |
+
COMMAND = "predict"
|
| 24 |
+
HELP = "use a pretrained chemprop model for prediction"
|
| 25 |
+
|
| 26 |
+
@classmethod
|
| 27 |
+
def add_args(cls, parser: ArgumentParser) -> ArgumentParser:
|
| 28 |
+
parser = add_common_args(parser)
|
| 29 |
+
return add_predict_args(parser)
|
| 30 |
+
|
| 31 |
+
@classmethod
|
| 32 |
+
def func(cls, args: Namespace):
|
| 33 |
+
args = process_common_args(args)
|
| 34 |
+
validate_common_args(args)
|
| 35 |
+
args = process_predict_args(args)
|
| 36 |
+
main(args)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def add_predict_args(parser: ArgumentParser) -> ArgumentParser:
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"-i",
|
| 42 |
+
"--test-path",
|
| 43 |
+
required=True,
|
| 44 |
+
type=Path,
|
| 45 |
+
help="Path to an input CSV file containing SMILES.",
|
| 46 |
+
)
|
| 47 |
+
parser.add_argument(
|
| 48 |
+
"-o",
|
| 49 |
+
"--output",
|
| 50 |
+
"--preds-path",
|
| 51 |
+
type=Path,
|
| 52 |
+
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'.",
|
| 53 |
+
)
|
| 54 |
+
parser.add_argument(
|
| 55 |
+
"--drop-extra-columns",
|
| 56 |
+
action="store_true",
|
| 57 |
+
help="Whether to drop all columns from the test data file besides the SMILES columns and the new prediction columns.",
|
| 58 |
+
)
|
| 59 |
+
parser.add_argument(
|
| 60 |
+
"--model-path",
|
| 61 |
+
required=True,
|
| 62 |
+
type=Path,
|
| 63 |
+
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.",
|
| 64 |
+
)
|
| 65 |
+
parser.add_argument(
|
| 66 |
+
"--target-columns",
|
| 67 |
+
nargs="+",
|
| 68 |
+
help="Column names to save the predictions to. If not provided, the predictions will be saved to columns named 'pred_0', 'pred_1', etc.",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# TODO: add uncertainty and calibration in v2.1
|
| 72 |
+
# unc_args = parser.add_argument_group("Uncertainty and calibration args")
|
| 73 |
+
# unc_args.add_argument("--cal-path")
|
| 74 |
+
# unc_args.add_argument("--cal-features-path")
|
| 75 |
+
# unc_args.add_argument("--cal-atom-features-path")
|
| 76 |
+
# unc_args.add_argument("--cal-bond-features-path")
|
| 77 |
+
# unc_args.add_argument("--cal-atom-descriptors-path")
|
| 78 |
+
# unc_args.add_argument(
|
| 79 |
+
# "--ensemble-variance",
|
| 80 |
+
# type=None,
|
| 81 |
+
# 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.",
|
| 82 |
+
# )
|
| 83 |
+
# unc_args.add_argument(
|
| 84 |
+
# "--individual-ensemble-predictions",
|
| 85 |
+
# type=bool,
|
| 86 |
+
# action="store_true",
|
| 87 |
+
# help="Whether to return the predictions made by each of the individual models rather than the average of the ensemble.",
|
| 88 |
+
# )
|
| 89 |
+
# unc_args.add_argument(
|
| 90 |
+
# "--uncertainty-method",
|
| 91 |
+
# #action=RegistryAction(TODO: make register for uncertainty methods)
|
| 92 |
+
# help="The method of calculating uncertainty.",
|
| 93 |
+
# )
|
| 94 |
+
# unc_args.add_argument(
|
| 95 |
+
# "--calibration-method",
|
| 96 |
+
# #action=RegistryAction(TODO: make register for calibration methods)
|
| 97 |
+
# help="Methods used for calibrating the uncertainty calculated with uncertainty method.",
|
| 98 |
+
# )
|
| 99 |
+
# unc_args.add_argument(
|
| 100 |
+
# "--evaluation-method",
|
| 101 |
+
# #action=RegistryAction(TODO: make register for evaluation methods)
|
| 102 |
+
# type=list[str],
|
| 103 |
+
# 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.",
|
| 104 |
+
# )
|
| 105 |
+
# unc_args.add_argument(
|
| 106 |
+
# "--evaluation-scores-path",
|
| 107 |
+
# help="Location to save the results of uncertainty evaluations.",
|
| 108 |
+
# )
|
| 109 |
+
# unc_args.add_argument(
|
| 110 |
+
# "--uncertainty-dropout-p",
|
| 111 |
+
# type=float,
|
| 112 |
+
# default=0.1,
|
| 113 |
+
# help="The probability to use for Monte Carlo dropout uncertainty estimation.",
|
| 114 |
+
# )
|
| 115 |
+
# unc_args.add_argument(
|
| 116 |
+
# "--dropout-sampling-size",
|
| 117 |
+
# type=int,
|
| 118 |
+
# default=10,
|
| 119 |
+
# help="The number of samples to use for Monte Carlo dropout uncertainty estimation. Distinct from the dropout used during training.",
|
| 120 |
+
# )
|
| 121 |
+
# unc_args.add_argument(
|
| 122 |
+
# "--calibration-interval-percentile",
|
| 123 |
+
# type=float,
|
| 124 |
+
# default=95,
|
| 125 |
+
# help="Sets the percentile used in the calibration methods. Must be in the range (1,100).",
|
| 126 |
+
# )
|
| 127 |
+
# unc_args.add_argument(
|
| 128 |
+
# "--regression-calibrator-metric",
|
| 129 |
+
# choices=['stdev', 'interval'],
|
| 130 |
+
# help="Regression calibrators can output either a stdev or an inverval.",
|
| 131 |
+
# )
|
| 132 |
+
# unc_args.add_argument(
|
| 133 |
+
# "--calibrationipath",
|
| 134 |
+
# help="Path to data file to be used for uncertainty calibration.",
|
| 135 |
+
# )
|
| 136 |
+
# unc_args.add_argument(
|
| 137 |
+
# "--calibration-features-path",
|
| 138 |
+
# type=list[str],
|
| 139 |
+
# help="Path to features data to be used with the uncertainty calibration dataset.",
|
| 140 |
+
# )
|
| 141 |
+
# unc_args.add_argument(
|
| 142 |
+
# "--calibration-phase-features-path",
|
| 143 |
+
# help=" ",
|
| 144 |
+
# )
|
| 145 |
+
# unc_args.add_argument(
|
| 146 |
+
# "--calibration-atom-descriptors-path",
|
| 147 |
+
# help="Path to the extra atom descriptors.",
|
| 148 |
+
# )
|
| 149 |
+
# unc_args.add_argument(
|
| 150 |
+
# "--calibration-bond-descriptors-path",
|
| 151 |
+
# help="Path to the extra bond descriptors that will be used as bond features to featurize a given molecule.",
|
| 152 |
+
# )
|
| 153 |
+
|
| 154 |
+
return parser
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def process_predict_args(args: Namespace) -> Namespace:
|
| 158 |
+
if args.test_path.suffix not in [".csv"]:
|
| 159 |
+
raise ArgumentError(
|
| 160 |
+
argument=None, message=f"Input data must be a CSV file. Got {args.test_path}"
|
| 161 |
+
)
|
| 162 |
+
if args.output is None:
|
| 163 |
+
args.output = args.test_path.parent / (args.test_path.stem + "_preds.csv")
|
| 164 |
+
if args.output.suffix not in [".csv", ".pkl"]:
|
| 165 |
+
raise ArgumentError(
|
| 166 |
+
argument=None, message=f"Output must be a CSV or Pickle file. Got {args.output}"
|
| 167 |
+
)
|
| 168 |
+
return args
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def find_models(model_path: Path):
|
| 172 |
+
if model_path.suffix in [".ckpt", ".pt"]:
|
| 173 |
+
return [model_path]
|
| 174 |
+
elif model_path.is_dir():
|
| 175 |
+
return list(model_path.rglob("*.ckpt")) + list(model_path.rglob("*.pt"))
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def make_prediction_for_model(
|
| 179 |
+
args: Namespace, model_path: Path, multicomponent: bool, output_path: Path
|
| 180 |
+
):
|
| 181 |
+
model = load_model(model_path, multicomponent)
|
| 182 |
+
|
| 183 |
+
bounded = any(
|
| 184 |
+
isinstance(model.criterion, LossFunctionRegistry[loss_function])
|
| 185 |
+
for loss_function in LossFunctionRegistry.keys()
|
| 186 |
+
if "bounded" in loss_function
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
format_kwargs = dict(
|
| 190 |
+
no_header_row=args.no_header_row,
|
| 191 |
+
smiles_cols=args.smiles_columns,
|
| 192 |
+
rxn_cols=args.reaction_columns,
|
| 193 |
+
target_cols=None,
|
| 194 |
+
ignore_cols=None,
|
| 195 |
+
splits_col=None,
|
| 196 |
+
weight_col=None,
|
| 197 |
+
bounded=bounded,
|
| 198 |
+
)
|
| 199 |
+
featurization_kwargs = dict(
|
| 200 |
+
features_generators=args.features_generators, keep_h=args.keep_h, add_h=args.add_h
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
test_data = build_data_from_files(
|
| 204 |
+
args.test_path,
|
| 205 |
+
**format_kwargs,
|
| 206 |
+
p_descriptors=args.descriptors_path,
|
| 207 |
+
p_atom_feats=args.atom_features_path,
|
| 208 |
+
p_bond_feats=args.bond_features_path,
|
| 209 |
+
p_atom_descs=args.atom_descriptors_path,
|
| 210 |
+
**featurization_kwargs,
|
| 211 |
+
)
|
| 212 |
+
logger.info(f"test size: {len(test_data[0])}")
|
| 213 |
+
test_dsets = [
|
| 214 |
+
make_dataset(d, args.rxn_mode, args.multi_hot_atom_featurizer_mode) for d in test_data
|
| 215 |
+
]
|
| 216 |
+
|
| 217 |
+
if multicomponent:
|
| 218 |
+
test_dset = data.MulticomponentDataset(test_dsets)
|
| 219 |
+
else:
|
| 220 |
+
test_dset = test_dsets[0]
|
| 221 |
+
|
| 222 |
+
# TODO: add uncertainty and calibration
|
| 223 |
+
# if args.cal_path is not None:
|
| 224 |
+
# cal_data = build_data_from_files(
|
| 225 |
+
# args.cal_path,
|
| 226 |
+
# **format_kwargs,
|
| 227 |
+
# target_columns=args.target_columns,
|
| 228 |
+
# p_features=args.cal_features_path,
|
| 229 |
+
# p_atom_feats=args.cal_atom_features_path,
|
| 230 |
+
# p_bond_feats=args.cal_bond_features_path,
|
| 231 |
+
# p_atom_descs=args.cal_atom_descriptors_path,
|
| 232 |
+
# **featurization_kwargs,
|
| 233 |
+
# )
|
| 234 |
+
# logger.info(f"calibration size: {len(cal_data)}")
|
| 235 |
+
# else:
|
| 236 |
+
# cal_data = None
|
| 237 |
+
|
| 238 |
+
test_loader = data.build_dataloader(test_dset, args.batch_size, args.num_workers, shuffle=False)
|
| 239 |
+
# TODO: add uncertainty and calibration
|
| 240 |
+
# if cal_data is not None:
|
| 241 |
+
# cal_dset = make_dataset(cal_data, bond_messages, args.rxn_mode)
|
| 242 |
+
# cal_loader = data.build_dataloader(cal_dset, args.batch_size, args.num_workers, shuffle=False)
|
| 243 |
+
# else:
|
| 244 |
+
# cal_loader = None
|
| 245 |
+
|
| 246 |
+
logger.info(model)
|
| 247 |
+
|
| 248 |
+
trainer = pl.Trainer(
|
| 249 |
+
logger=False, enable_progress_bar=True, accelerator=args.accelerator, devices=args.devices
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
predss = trainer.predict(model, test_loader)
|
| 253 |
+
|
| 254 |
+
# TODO: add uncertainty and calibration
|
| 255 |
+
# if cal_dset is not None:
|
| 256 |
+
# if args.task_type == "regression":
|
| 257 |
+
# model.loc, model.scale = float(scaler.mean_), float(scaler.scale_)
|
| 258 |
+
# predss_cal = trainer.predict(model, cal_loader)[0]
|
| 259 |
+
|
| 260 |
+
# TODO: might want to write a shared function for this as train.py might also want to do this.
|
| 261 |
+
df_test = pd.read_csv(args.test_path)
|
| 262 |
+
preds = torch.concat(predss, 0)
|
| 263 |
+
|
| 264 |
+
if isinstance(model.predictor, MulticlassClassificationFFN):
|
| 265 |
+
preds = torch.argmax(preds, dim=-1)
|
| 266 |
+
|
| 267 |
+
if args.target_columns is not None:
|
| 268 |
+
assert (
|
| 269 |
+
len(args.target_columns) == model.n_tasks
|
| 270 |
+
), "Number of target columns must match the number of tasks."
|
| 271 |
+
target_columns = args.target_columns
|
| 272 |
+
else:
|
| 273 |
+
target_columns = [
|
| 274 |
+
f"pred_{i}" for i in range(preds.shape[1])
|
| 275 |
+
] # TODO: need to improve this for cases like multi-task MVE and multi-task multiclass
|
| 276 |
+
|
| 277 |
+
df_test[target_columns] = preds
|
| 278 |
+
if output_path.suffix == ".pkl":
|
| 279 |
+
df_test = df_test.reset_index(drop=True)
|
| 280 |
+
df_test.to_pickle(output_path)
|
| 281 |
+
else:
|
| 282 |
+
df_test.to_csv(output_path, index=False)
|
| 283 |
+
logger.info(f"Predictions saved to '{output_path}'")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def main(args):
|
| 287 |
+
match (args.smiles_columns, args.reaction_columns):
|
| 288 |
+
case [None, None]:
|
| 289 |
+
n_components = 1
|
| 290 |
+
case [_, None]:
|
| 291 |
+
n_components = len(args.smiles_columns)
|
| 292 |
+
case [None, _]:
|
| 293 |
+
n_components = len(args.reaction_columns)
|
| 294 |
+
case _:
|
| 295 |
+
n_components = len(args.smiles_columns) + len(args.reaction_columns)
|
| 296 |
+
|
| 297 |
+
multicomponent = n_components > 1
|
| 298 |
+
|
| 299 |
+
model_paths = find_models(args.model_path)
|
| 300 |
+
|
| 301 |
+
for i, model_path in enumerate(model_paths):
|
| 302 |
+
logger.info(f"Predicting with model at '{model_path}'")
|
| 303 |
+
output_path = args.output.parent / Path(
|
| 304 |
+
str(args.output.stem) + f"_{i}" + str(args.output.suffix)
|
| 305 |
+
)
|
| 306 |
+
make_prediction_for_model(args, model_path, multicomponent, output_path)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
parser = ArgumentParser()
|
| 311 |
+
parser = PredictSubcommand.add_args(parser)
|
| 312 |
+
|
| 313 |
+
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)
|
| 314 |
+
args = parser.parse_args()
|
| 315 |
+
args = PredictSubcommand.func(args)
|
chemprop/cli/train.py
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import sys
|
| 4 |
+
from copy import deepcopy
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
from configargparse import ArgumentError, ArgumentParser, Namespace
|
| 12 |
+
from lightning import pytorch as pl
|
| 13 |
+
from lightning.pytorch.callbacks import EarlyStopping, ModelCheckpoint
|
| 14 |
+
from lightning.pytorch.loggers import CSVLogger, TensorBoardLogger
|
| 15 |
+
|
| 16 |
+
from chemprop.cli.common import add_common_args, process_common_args, validate_common_args
|
| 17 |
+
from chemprop.cli.conf import NOW
|
| 18 |
+
from chemprop.cli.utils import (
|
| 19 |
+
LookupAction,
|
| 20 |
+
Subcommand,
|
| 21 |
+
build_data_from_files,
|
| 22 |
+
get_column_names,
|
| 23 |
+
make_dataset,
|
| 24 |
+
parse_indices,
|
| 25 |
+
)
|
| 26 |
+
from chemprop.cli.utils.args import uppercase
|
| 27 |
+
from chemprop.data import (
|
| 28 |
+
MoleculeDataset,
|
| 29 |
+
MolGraphDataset,
|
| 30 |
+
MulticomponentDataset,
|
| 31 |
+
ReactionDatapoint,
|
| 32 |
+
SplitType,
|
| 33 |
+
build_dataloader,
|
| 34 |
+
make_split_indices,
|
| 35 |
+
split_data_by_indices,
|
| 36 |
+
)
|
| 37 |
+
from chemprop.featurizers import MoleculeFeaturizerRegistry
|
| 38 |
+
from chemprop.models import MPNN, MulticomponentMPNN, save_model
|
| 39 |
+
from chemprop.nn import AggregationRegistry, LossFunctionRegistry, MetricRegistry, PredictorRegistry
|
| 40 |
+
from chemprop.nn.message_passing import (
|
| 41 |
+
AtomMessagePassing,
|
| 42 |
+
BondMessagePassing,
|
| 43 |
+
MulticomponentMessagePassing,
|
| 44 |
+
)
|
| 45 |
+
from chemprop.nn.transforms import GraphTransform, ScaleTransform, UnscaleTransform
|
| 46 |
+
from chemprop.nn.utils import Activation
|
| 47 |
+
from chemprop.utils import Factory
|
| 48 |
+
|
| 49 |
+
logger = logging.getLogger(__name__)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TrainSubcommand(Subcommand):
|
| 53 |
+
COMMAND = "train"
|
| 54 |
+
HELP = "train a chemprop model"
|
| 55 |
+
parser = None
|
| 56 |
+
|
| 57 |
+
@classmethod
|
| 58 |
+
def add_args(cls, parser: ArgumentParser) -> ArgumentParser:
|
| 59 |
+
parser = add_common_args(parser)
|
| 60 |
+
parser = add_train_args(parser)
|
| 61 |
+
cls.parser = parser
|
| 62 |
+
return parser
|
| 63 |
+
|
| 64 |
+
@classmethod
|
| 65 |
+
def func(cls, args: Namespace):
|
| 66 |
+
args = process_common_args(args)
|
| 67 |
+
validate_common_args(args)
|
| 68 |
+
args = process_train_args(args)
|
| 69 |
+
validate_train_args(args)
|
| 70 |
+
|
| 71 |
+
args.output_dir.mkdir(exist_ok=True, parents=True)
|
| 72 |
+
save_config(cls.parser, args)
|
| 73 |
+
main(args)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def add_train_args(parser: ArgumentParser) -> ArgumentParser:
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"--config-path",
|
| 79 |
+
type=Path,
|
| 80 |
+
is_config_file=True,
|
| 81 |
+
help="Path to a configuration file. Command line arguments override values in the configuration file.",
|
| 82 |
+
)
|
| 83 |
+
parser.add_argument(
|
| 84 |
+
"-i",
|
| 85 |
+
"--data-path",
|
| 86 |
+
type=Path,
|
| 87 |
+
help="Path to an input CSV file containing SMILES and the associated target values.",
|
| 88 |
+
)
|
| 89 |
+
parser.add_argument(
|
| 90 |
+
"-o",
|
| 91 |
+
"--output-dir",
|
| 92 |
+
"--save-dir",
|
| 93 |
+
type=Path,
|
| 94 |
+
help="Directory where training outputs will be saved. Defaults to 'CURRENT_DIRECTORY/chemprop_training/STEM_OF_INPUT/TIME_STAMP'.",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# TODO: Add in v2.1
|
| 98 |
+
# parser.add_argument(
|
| 99 |
+
# "--checkpoint-dir",
|
| 100 |
+
# help="Directory from which to load model checkpoints (walks directory and ensembles all models that are found).",
|
| 101 |
+
# )
|
| 102 |
+
# parser.add_argument("--checkpoint-path", help="Path to model checkpoint (:code:`.pt` file).")
|
| 103 |
+
# parser.add_argument(
|
| 104 |
+
# "--checkpoint-paths",
|
| 105 |
+
# type=list[str],
|
| 106 |
+
# help="List of paths to model checkpoints (:code:`.pt` files).",
|
| 107 |
+
# )
|
| 108 |
+
# # TODO: Is this a prediction only argument?
|
| 109 |
+
# parser.add_argument(
|
| 110 |
+
# "--checkpoint",
|
| 111 |
+
# 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.",
|
| 112 |
+
# )
|
| 113 |
+
|
| 114 |
+
# TODO: Add in v2.1; see if we can tell lightning how often to log training loss
|
| 115 |
+
# parser.add_argument(
|
| 116 |
+
# "--log-frequency",
|
| 117 |
+
# type=int,
|
| 118 |
+
# default=10,
|
| 119 |
+
# help="The number of batches between each logging of the training loss.",
|
| 120 |
+
# )
|
| 121 |
+
|
| 122 |
+
transfer_args = parser.add_argument_group("transfer learning args")
|
| 123 |
+
transfer_args.add_argument(
|
| 124 |
+
"--model-frzn",
|
| 125 |
+
help="Path to model checkpoint file to be loaded for overwriting and freezing weights.",
|
| 126 |
+
)
|
| 127 |
+
transfer_args.add_argument(
|
| 128 |
+
"--frzn-ffn-layers",
|
| 129 |
+
type=int,
|
| 130 |
+
default=0,
|
| 131 |
+
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.",
|
| 132 |
+
)
|
| 133 |
+
# transfer_args.add_argument(
|
| 134 |
+
# "--freeze-first-only",
|
| 135 |
+
# action="store_true",
|
| 136 |
+
# 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)",
|
| 137 |
+
# )
|
| 138 |
+
|
| 139 |
+
# TODO: Add in v2.1
|
| 140 |
+
# parser.add_argument(
|
| 141 |
+
# "--resume-experiment",
|
| 142 |
+
# action="store_true",
|
| 143 |
+
# help="Whether to resume the experiment. Loads test results from any folds that have already been completed and skips training those folds.",
|
| 144 |
+
# )
|
| 145 |
+
# parser.add_argument(
|
| 146 |
+
# "--config-path",
|
| 147 |
+
# 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.",
|
| 148 |
+
# )
|
| 149 |
+
parser.add_argument(
|
| 150 |
+
"--ensemble-size",
|
| 151 |
+
type=int,
|
| 152 |
+
default=1,
|
| 153 |
+
help="Number of models in ensemble for each splitting of data.",
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# TODO: Add in v2.2
|
| 157 |
+
# abt_args = parser.add_argument_group("atom/bond target args")
|
| 158 |
+
# abt_args.add_argument(
|
| 159 |
+
# "--is-atom-bond-targets",
|
| 160 |
+
# action="store_true",
|
| 161 |
+
# help="Whether this is atomic/bond properties prediction.",
|
| 162 |
+
# )
|
| 163 |
+
# abt_args.add_argument(
|
| 164 |
+
# "--no-adding-bond-types",
|
| 165 |
+
# action="store_true",
|
| 166 |
+
# 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`.",
|
| 167 |
+
# )
|
| 168 |
+
# abt_args.add_argument(
|
| 169 |
+
# "--keeping-atom-map",
|
| 170 |
+
# action="store_true",
|
| 171 |
+
# 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`.",
|
| 172 |
+
# )
|
| 173 |
+
# abt_args.add_argument(
|
| 174 |
+
# "--no-shared-atom-bond-ffn",
|
| 175 |
+
# action="store_true",
|
| 176 |
+
# help="Whether the FFN weights for atom and bond targets should be independent between tasks.",
|
| 177 |
+
# )
|
| 178 |
+
# abt_args.add_argument(
|
| 179 |
+
# "--weights-ffn-num-layers",
|
| 180 |
+
# type=int,
|
| 181 |
+
# default=2,
|
| 182 |
+
# help="Number of layers in FFN for determining weights used in constrained targets.",
|
| 183 |
+
# )
|
| 184 |
+
|
| 185 |
+
mp_args = parser.add_argument_group("message passing")
|
| 186 |
+
mp_args.add_argument(
|
| 187 |
+
"--message-hidden-dim", type=int, default=300, help="hidden dimension of the messages"
|
| 188 |
+
)
|
| 189 |
+
mp_args.add_argument(
|
| 190 |
+
"--message-bias", action="store_true", help="add bias to the message passing layers"
|
| 191 |
+
)
|
| 192 |
+
mp_args.add_argument("--depth", type=int, default=3, help="Number of message passing steps.")
|
| 193 |
+
mp_args.add_argument(
|
| 194 |
+
"--undirected",
|
| 195 |
+
action="store_true",
|
| 196 |
+
help="Pass messages on undirected bonds/edges (always sum the two relevant bond vectors).",
|
| 197 |
+
)
|
| 198 |
+
mp_args.add_argument(
|
| 199 |
+
"--dropout",
|
| 200 |
+
type=float,
|
| 201 |
+
default=0.0,
|
| 202 |
+
help="dropout probability in message passing/FFN layers",
|
| 203 |
+
)
|
| 204 |
+
mp_args.add_argument(
|
| 205 |
+
"--mpn-shared",
|
| 206 |
+
action="store_true",
|
| 207 |
+
help="Whether to use the same message passing neural network for all input molecules. Only relevant if :code:`number_of_molecules > 1`",
|
| 208 |
+
)
|
| 209 |
+
mp_args.add_argument(
|
| 210 |
+
"--activation",
|
| 211 |
+
type=uppercase,
|
| 212 |
+
default="RELU",
|
| 213 |
+
choices=list(Activation.keys()),
|
| 214 |
+
help="activation function in message passing/FFN layers",
|
| 215 |
+
)
|
| 216 |
+
mp_args.add_argument(
|
| 217 |
+
"--aggregation",
|
| 218 |
+
"--agg",
|
| 219 |
+
default="mean",
|
| 220 |
+
action=LookupAction(AggregationRegistry),
|
| 221 |
+
help="the aggregation mode to use during graph predictor",
|
| 222 |
+
)
|
| 223 |
+
mp_args.add_argument(
|
| 224 |
+
"--aggregation-norm",
|
| 225 |
+
type=float,
|
| 226 |
+
default=100,
|
| 227 |
+
help="normalization factor by which to divide summed up atomic features for 'norm' aggregation",
|
| 228 |
+
)
|
| 229 |
+
mp_args.add_argument(
|
| 230 |
+
"--atom-messages", action="store_true", help="pass messages on atoms rather than bonds"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# TODO: Add in v2.1
|
| 234 |
+
# mpsolv_args = parser.add_argument_group("message passing with solvent")
|
| 235 |
+
# mpsolv_args.add_argument(
|
| 236 |
+
# "--reaction-solvent",
|
| 237 |
+
# action="store_true",
|
| 238 |
+
# help="Whether to adjust the MPNN layer to take as input a reaction and a molecule, and to encode them with separate MPNNs.",
|
| 239 |
+
# )
|
| 240 |
+
# mpsolv_args.add_argument(
|
| 241 |
+
# "--bias-solvent",
|
| 242 |
+
# action="store_true",
|
| 243 |
+
# help="Whether to add bias to linear layers for solvent MPN if :code:`reaction_solvent` is True.",
|
| 244 |
+
# )
|
| 245 |
+
# mpsolv_args.add_argument(
|
| 246 |
+
# "--hidden-size-solvent",
|
| 247 |
+
# type=int,
|
| 248 |
+
# default=300,
|
| 249 |
+
# help="Dimensionality of hidden layers in solvent MPN if :code:`reaction_solvent` is True.",
|
| 250 |
+
# )
|
| 251 |
+
# mpsolv_args.add_argument(
|
| 252 |
+
# "--depth-solvent",
|
| 253 |
+
# type=int,
|
| 254 |
+
# default=3,
|
| 255 |
+
# help="Number of message passing steps for solvent if :code:`reaction_solvent` is True.",
|
| 256 |
+
# )
|
| 257 |
+
|
| 258 |
+
ffn_args = parser.add_argument_group("FFN args")
|
| 259 |
+
ffn_args.add_argument(
|
| 260 |
+
"--ffn-hidden-dim", type=int, default=300, help="hidden dimension in the FFN top model"
|
| 261 |
+
)
|
| 262 |
+
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?
|
| 263 |
+
"--ffn-num-layers", type=int, default=1, help="number of layers in FFN top model"
|
| 264 |
+
)
|
| 265 |
+
# TODO: Decide if we want to implment this in v2
|
| 266 |
+
# ffn_args.add_argument(
|
| 267 |
+
# "--features-only",
|
| 268 |
+
# action="store_true",
|
| 269 |
+
# help="Use only the additional features in an FFN, no graph network.",
|
| 270 |
+
# )
|
| 271 |
+
|
| 272 |
+
extra_mpnn_args = parser.add_argument_group("extra MPNN args")
|
| 273 |
+
extra_mpnn_args.add_argument(
|
| 274 |
+
"--no-batch-norm",
|
| 275 |
+
action="store_true",
|
| 276 |
+
help="Don't use batch normalization after aggregation.",
|
| 277 |
+
)
|
| 278 |
+
extra_mpnn_args.add_argument(
|
| 279 |
+
"--multiclass-num-classes",
|
| 280 |
+
type=int,
|
| 281 |
+
default=3,
|
| 282 |
+
help="Number of classes when running multiclass classification.",
|
| 283 |
+
)
|
| 284 |
+
# TODO: Add in v2.1
|
| 285 |
+
# extra_mpnn_args.add_argument(
|
| 286 |
+
# "--spectral-activation",
|
| 287 |
+
# default="exp",
|
| 288 |
+
# choices=["softplus", "exp"],
|
| 289 |
+
# help="Indicates which function to use in task_type spectra training to constrain outputs to be positive.",
|
| 290 |
+
# )
|
| 291 |
+
|
| 292 |
+
train_data_args = parser.add_argument_group("training input data args")
|
| 293 |
+
train_data_args.add_argument(
|
| 294 |
+
"-w",
|
| 295 |
+
"--weight-column",
|
| 296 |
+
help="the name of the column in the input CSV containg individual data weights",
|
| 297 |
+
)
|
| 298 |
+
train_data_args.add_argument(
|
| 299 |
+
"--target-columns",
|
| 300 |
+
nargs="+",
|
| 301 |
+
help="Name of the columns containing target values. By default, uses all columns except the SMILES column and the :code:`ignore_columns`.",
|
| 302 |
+
)
|
| 303 |
+
train_data_args.add_argument(
|
| 304 |
+
"--ignore-columns",
|
| 305 |
+
nargs="+",
|
| 306 |
+
help="Name of the columns to ignore when :code:`target_columns` is not provided.",
|
| 307 |
+
)
|
| 308 |
+
# TODO: Add in v2.1
|
| 309 |
+
# train_data_args.add_argument(
|
| 310 |
+
# "--spectra-phase-mask-path",
|
| 311 |
+
# help="Path to a file containing a phase mask array, used for excluding particular regions in spectra predictions.",
|
| 312 |
+
# )
|
| 313 |
+
|
| 314 |
+
train_args = parser.add_argument_group("training args")
|
| 315 |
+
train_args.add_argument(
|
| 316 |
+
"-t",
|
| 317 |
+
"--task-type",
|
| 318 |
+
default="regression",
|
| 319 |
+
action=LookupAction(PredictorRegistry),
|
| 320 |
+
help="Type of dataset. This determines the default loss function used during training. Defaults to regression.",
|
| 321 |
+
)
|
| 322 |
+
train_args.add_argument(
|
| 323 |
+
"-l",
|
| 324 |
+
"--loss-function",
|
| 325 |
+
action=LookupAction(LossFunctionRegistry),
|
| 326 |
+
help="Loss function to use during training. If not specified, will use the default loss function for the given task type (see documentation).",
|
| 327 |
+
)
|
| 328 |
+
train_args.add_argument(
|
| 329 |
+
"--v-kl",
|
| 330 |
+
"--evidential-regularization",
|
| 331 |
+
type=float,
|
| 332 |
+
default=0.0,
|
| 333 |
+
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.",
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
train_args.add_argument(
|
| 337 |
+
"--eps", type=float, default=1e-8, help="evidential regularization epsilon"
|
| 338 |
+
)
|
| 339 |
+
# TODO: Add in v2.1
|
| 340 |
+
# train_args.add_argument( # TODO: Is threshold the same thing as the spectra target floor? I'm not sure but combined them.
|
| 341 |
+
# "-T",
|
| 342 |
+
# "--threshold",
|
| 343 |
+
# "--spectra-target-floor",
|
| 344 |
+
# type=float,
|
| 345 |
+
# default=1e-8,
|
| 346 |
+
# 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.",
|
| 347 |
+
# )
|
| 348 |
+
train_args.add_argument(
|
| 349 |
+
"--metrics",
|
| 350 |
+
"--metric",
|
| 351 |
+
nargs="+",
|
| 352 |
+
action=LookupAction(MetricRegistry),
|
| 353 |
+
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",
|
| 354 |
+
)
|
| 355 |
+
# TODO: Add in v2.1
|
| 356 |
+
# train_args.add_argument(
|
| 357 |
+
# "--show-individual-scores",
|
| 358 |
+
# action="store_true",
|
| 359 |
+
# help="Show all scores for individual targets, not just average, at the end.",
|
| 360 |
+
# )
|
| 361 |
+
train_args.add_argument(
|
| 362 |
+
"--task-weights",
|
| 363 |
+
nargs="+",
|
| 364 |
+
type=float,
|
| 365 |
+
help="the weight to apply to an individual task in the overall loss",
|
| 366 |
+
)
|
| 367 |
+
train_args.add_argument(
|
| 368 |
+
"--warmup-epochs",
|
| 369 |
+
type=int,
|
| 370 |
+
default=2,
|
| 371 |
+
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`.",
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
train_args.add_argument("--init-lr", type=float, default=1e-4, help="Initial learning rate.")
|
| 375 |
+
train_args.add_argument("--max-lr", type=float, default=1e-3, help="Maximum learning rate.")
|
| 376 |
+
train_args.add_argument("--final-lr", type=float, default=1e-4, help="Final learning rate.")
|
| 377 |
+
train_args.add_argument(
|
| 378 |
+
"--epochs", type=int, default=50, help="the number of epochs to train over"
|
| 379 |
+
)
|
| 380 |
+
train_args.add_argument(
|
| 381 |
+
"--patience",
|
| 382 |
+
type=int,
|
| 383 |
+
default=None,
|
| 384 |
+
help="Number of epochs to wait for improvement before early stopping.",
|
| 385 |
+
)
|
| 386 |
+
train_args.add_argument(
|
| 387 |
+
"--grad-clip",
|
| 388 |
+
type=float,
|
| 389 |
+
help="Passed directly to the lightning trainer which controls grad clipping. See the :code:`Trainer()` docstring for details.",
|
| 390 |
+
)
|
| 391 |
+
# TODO: Add in v2.1
|
| 392 |
+
# train_args.add_argument(
|
| 393 |
+
# "--class-balance",
|
| 394 |
+
# action="store_true",
|
| 395 |
+
# help="Trains with an equal number of positives and negatives in each batch.",
|
| 396 |
+
# )
|
| 397 |
+
|
| 398 |
+
split_args = parser.add_argument_group("split args")
|
| 399 |
+
split_args.add_argument(
|
| 400 |
+
"--split",
|
| 401 |
+
"--split-type",
|
| 402 |
+
type=uppercase,
|
| 403 |
+
default="RANDOM",
|
| 404 |
+
choices=list(SplitType.keys()),
|
| 405 |
+
help="Method of splitting the data into train/val/test (case insensitive).",
|
| 406 |
+
)
|
| 407 |
+
split_args.add_argument(
|
| 408 |
+
"--split-sizes",
|
| 409 |
+
type=float,
|
| 410 |
+
nargs=3,
|
| 411 |
+
default=[0.8, 0.1, 0.1],
|
| 412 |
+
help="Split proportions for train/validation/test sets.",
|
| 413 |
+
)
|
| 414 |
+
split_args.add_argument(
|
| 415 |
+
"--split-key-molecule",
|
| 416 |
+
type=int,
|
| 417 |
+
default=0,
|
| 418 |
+
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.",
|
| 419 |
+
)
|
| 420 |
+
split_args.add_argument(
|
| 421 |
+
"-k",
|
| 422 |
+
"--num-folds",
|
| 423 |
+
type=int,
|
| 424 |
+
default=1,
|
| 425 |
+
help="Number of folds when performing cross validation.",
|
| 426 |
+
)
|
| 427 |
+
split_args.add_argument(
|
| 428 |
+
"--save-smiles-splits",
|
| 429 |
+
action="store_true",
|
| 430 |
+
help="Save smiles for each train/val/test splits for prediction convenience later.",
|
| 431 |
+
)
|
| 432 |
+
split_args.add_argument(
|
| 433 |
+
"--splits-file",
|
| 434 |
+
type=Path,
|
| 435 |
+
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.",
|
| 436 |
+
)
|
| 437 |
+
train_data_args.add_argument(
|
| 438 |
+
"--splits-column",
|
| 439 |
+
help="Name of the column in the input CSV file containing 'train', 'val', or 'test' for each row.",
|
| 440 |
+
)
|
| 441 |
+
split_args.add_argument(
|
| 442 |
+
"--data-seed",
|
| 443 |
+
type=int,
|
| 444 |
+
default=0,
|
| 445 |
+
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.",
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
parser.add_argument(
|
| 449 |
+
"--pytorch-seed",
|
| 450 |
+
type=int,
|
| 451 |
+
default=None,
|
| 452 |
+
help="Seed for PyTorch randomness (e.g., random initial weights).",
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
return parser
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def process_train_args(args: Namespace) -> Namespace:
|
| 459 |
+
if args.config_path is None and args.data_path is None:
|
| 460 |
+
raise ArgumentError(argument=None, message="Data path must be provided for training.")
|
| 461 |
+
|
| 462 |
+
if args.data_path.suffix not in [".csv"]:
|
| 463 |
+
raise ArgumentError(
|
| 464 |
+
argument=None, message=f"Input data must be a CSV file. Got {args.data_path}"
|
| 465 |
+
)
|
| 466 |
+
if args.output_dir is None:
|
| 467 |
+
args.output_dir = Path(f"chemprop_training/{args.data_path.stem}/{NOW}")
|
| 468 |
+
|
| 469 |
+
return args
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def validate_train_args(args):
|
| 473 |
+
pass
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def normalize_inputs(train_dset, val_dset, args):
|
| 477 |
+
multicomponent = isinstance(train_dset, MulticomponentDataset)
|
| 478 |
+
num_components = train_dset.n_components if multicomponent else 1
|
| 479 |
+
|
| 480 |
+
X_d_transform = None
|
| 481 |
+
V_f_transforms = [nn.Identity()] * num_components
|
| 482 |
+
E_f_transforms = [nn.Identity()] * num_components
|
| 483 |
+
V_d_transforms = [None] * num_components
|
| 484 |
+
graph_transforms = []
|
| 485 |
+
|
| 486 |
+
d_xd = train_dset.d_xd
|
| 487 |
+
d_vf = train_dset.d_vf
|
| 488 |
+
d_ef = train_dset.d_ef
|
| 489 |
+
d_vd = train_dset.d_vd
|
| 490 |
+
|
| 491 |
+
if d_xd > 0 and not args.no_descriptor_scaling:
|
| 492 |
+
scaler = train_dset.normalize_inputs("X_d")
|
| 493 |
+
val_dset.normalize_inputs("X_d", scaler)
|
| 494 |
+
|
| 495 |
+
scaler = scaler if not isinstance(scaler, list) else scaler[0]
|
| 496 |
+
|
| 497 |
+
if scaler is not None:
|
| 498 |
+
logger.info(
|
| 499 |
+
f"Descriptors: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}"
|
| 500 |
+
)
|
| 501 |
+
X_d_transform = ScaleTransform.from_standard_scaler(scaler)
|
| 502 |
+
|
| 503 |
+
if d_vf > 0 and not args.no_atom_feature_scaling:
|
| 504 |
+
scaler = train_dset.normalize_inputs("V_f")
|
| 505 |
+
val_dset.normalize_inputs("V_f", scaler)
|
| 506 |
+
|
| 507 |
+
scalers = [scaler] if not isinstance(scaler, list) else scaler
|
| 508 |
+
|
| 509 |
+
for i, scaler in enumerate(scalers):
|
| 510 |
+
if scaler is None:
|
| 511 |
+
continue
|
| 512 |
+
|
| 513 |
+
logger.info(
|
| 514 |
+
f"Atom features for mol {i}: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}"
|
| 515 |
+
)
|
| 516 |
+
featurizer = (
|
| 517 |
+
train_dset.datasets[i].featurizer if multicomponent else train_dset.featurizer
|
| 518 |
+
)
|
| 519 |
+
V_f_transforms[i] = ScaleTransform.from_standard_scaler(
|
| 520 |
+
scaler, pad=featurizer.atom_fdim - featurizer.extra_atom_fdim
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
if d_ef > 0 and not args.no_bond_feature_scaling:
|
| 524 |
+
scaler = train_dset.normalize_inputs("E_f")
|
| 525 |
+
val_dset.normalize_inputs("E_f", scaler)
|
| 526 |
+
|
| 527 |
+
scalers = [scaler] if not isinstance(scaler, list) else scaler
|
| 528 |
+
|
| 529 |
+
for i, scaler in enumerate(scalers):
|
| 530 |
+
if scaler is None:
|
| 531 |
+
continue
|
| 532 |
+
|
| 533 |
+
logger.info(
|
| 534 |
+
f"Bond features for mol {i}: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}"
|
| 535 |
+
)
|
| 536 |
+
featurizer = (
|
| 537 |
+
train_dset.datasets[i].featurizer if multicomponent else train_dset.featurizer
|
| 538 |
+
)
|
| 539 |
+
E_f_transforms[i] = ScaleTransform.from_standard_scaler(
|
| 540 |
+
scaler, pad=featurizer.bond_fdim - featurizer.extra_bond_fdim
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
for V_f_transform, E_f_transform in zip(V_f_transforms, E_f_transforms):
|
| 544 |
+
graph_transforms.append(GraphTransform(V_f_transform, E_f_transform))
|
| 545 |
+
|
| 546 |
+
if d_vd > 0 and not args.no_atom_descriptor_scaling:
|
| 547 |
+
scaler = train_dset.normalize_inputs("V_d")
|
| 548 |
+
val_dset.normalize_inputs("V_d", scaler)
|
| 549 |
+
|
| 550 |
+
scalers = [scaler] if not isinstance(scaler, list) else scaler
|
| 551 |
+
|
| 552 |
+
for i, scaler in enumerate(scalers):
|
| 553 |
+
if scaler is None:
|
| 554 |
+
continue
|
| 555 |
+
|
| 556 |
+
logger.info(
|
| 557 |
+
f"Atom descriptors for mol {i}: loc = {np.array2string(scaler.mean_, precision=3)}, scale = {np.array2string(scaler.scale_, precision=3)}"
|
| 558 |
+
)
|
| 559 |
+
V_d_transforms[i] = ScaleTransform.from_standard_scaler(scaler)
|
| 560 |
+
|
| 561 |
+
return X_d_transform, graph_transforms, V_d_transforms
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def save_config(parser: ArgumentParser, args: Namespace):
|
| 565 |
+
config_args = deepcopy(args)
|
| 566 |
+
for key, value in vars(config_args).items():
|
| 567 |
+
if isinstance(value, Path):
|
| 568 |
+
setattr(config_args, key, str(value))
|
| 569 |
+
|
| 570 |
+
for key in ["atom_features_path", "atom_descriptors_path", "bond_features_path"]:
|
| 571 |
+
if getattr(config_args, key) is not None:
|
| 572 |
+
for index, path in getattr(config_args, key).items():
|
| 573 |
+
getattr(config_args, key)[index] = str(path)
|
| 574 |
+
|
| 575 |
+
config_path = str(args.output_dir / "config.toml")
|
| 576 |
+
parser.write_config_file(parsed_namespace=config_args, output_file_paths=[config_path])
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def save_smiles_splits(args: Namespace, output_dir, train_dset, val_dset, test_dset):
|
| 580 |
+
train_smis = train_dset.smiles
|
| 581 |
+
df_train = pd.DataFrame(train_smis, columns=args.smiles_columns)
|
| 582 |
+
df_train.to_csv(output_dir / "train_smiles.csv", index=False)
|
| 583 |
+
|
| 584 |
+
val_smis = val_dset.smiles
|
| 585 |
+
df_val = pd.DataFrame(val_smis, columns=args.smiles_columns)
|
| 586 |
+
df_val.to_csv(output_dir / "val_smiles.csv", index=False)
|
| 587 |
+
|
| 588 |
+
if test_dset is not None:
|
| 589 |
+
test_smis = test_dset.smiles
|
| 590 |
+
df_test = pd.DataFrame(test_smis, columns=args.smiles_columns)
|
| 591 |
+
df_test.to_csv(output_dir / "test_smiles.csv", index=False)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def build_splits(args, format_kwargs, featurization_kwargs):
|
| 595 |
+
"""build the train/val/test splits"""
|
| 596 |
+
logger.info(f"Pulling data from file: {args.data_path}")
|
| 597 |
+
all_data = build_data_from_files(
|
| 598 |
+
args.data_path,
|
| 599 |
+
p_descriptors=args.descriptors_path,
|
| 600 |
+
p_atom_feats=args.atom_features_path,
|
| 601 |
+
p_bond_feats=args.bond_features_path,
|
| 602 |
+
p_atom_descs=args.atom_descriptors_path,
|
| 603 |
+
**format_kwargs,
|
| 604 |
+
**featurization_kwargs,
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
if args.splits_column is not None:
|
| 608 |
+
df = pd.read_csv(
|
| 609 |
+
args.data_path, header=None if args.no_header_row else "infer", index_col=False
|
| 610 |
+
)
|
| 611 |
+
grouped = df.groupby(df[args.splits_column].str.lower())
|
| 612 |
+
train_indices = grouped.groups.get("train", pd.Index([])).tolist()
|
| 613 |
+
val_indices = grouped.groups.get("val", pd.Index([])).tolist()
|
| 614 |
+
test_indices = grouped.groups.get("test", pd.Index([])).tolist()
|
| 615 |
+
train_indices, val_indices, test_indices = [train_indices], [val_indices], [test_indices]
|
| 616 |
+
|
| 617 |
+
elif args.splits_file is not None:
|
| 618 |
+
with open(args.splits_file, "rb") as json_file:
|
| 619 |
+
split_idxss = json.load(json_file)
|
| 620 |
+
train_indices = [parse_indices(d["train"]) for d in split_idxss]
|
| 621 |
+
val_indices = [parse_indices(d["val"]) for d in split_idxss]
|
| 622 |
+
test_indices = [parse_indices(d["test"]) for d in split_idxss]
|
| 623 |
+
|
| 624 |
+
else:
|
| 625 |
+
splitting_data = all_data[args.split_key_molecule]
|
| 626 |
+
if isinstance(splitting_data[0], ReactionDatapoint):
|
| 627 |
+
splitting_mols = [datapoint.rct for datapoint in splitting_data]
|
| 628 |
+
else:
|
| 629 |
+
splitting_mols = [datapoint.mol for datapoint in splitting_data]
|
| 630 |
+
train_indices, val_indices, test_indices = make_split_indices(
|
| 631 |
+
splitting_mols, args.split, args.split_sizes, args.data_seed, args.num_folds
|
| 632 |
+
)
|
| 633 |
+
if not (
|
| 634 |
+
SplitType.get(args.split) == SplitType.CV_NO_VAL
|
| 635 |
+
or SplitType.get(args.split) == SplitType.CV
|
| 636 |
+
):
|
| 637 |
+
train_indices, val_indices, test_indices = (
|
| 638 |
+
[train_indices],
|
| 639 |
+
[val_indices],
|
| 640 |
+
[test_indices],
|
| 641 |
+
)
|
| 642 |
+
|
| 643 |
+
train_data, val_data, test_data = split_data_by_indices(
|
| 644 |
+
all_data, train_indices, val_indices, test_indices
|
| 645 |
+
)
|
| 646 |
+
for i_split in range(len(train_data)):
|
| 647 |
+
sizes = [len(train_data[i_split][0]), len(val_data[i_split][0]), len(test_data[i_split][0])]
|
| 648 |
+
logger.info(f"train/val/test split_{i_split} sizes: {sizes}")
|
| 649 |
+
|
| 650 |
+
return train_data, val_data, test_data
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def build_datasets(args, train_data, val_data, test_data):
|
| 654 |
+
"""build the train/val/test datasets, where :attr:`test_data` may be None"""
|
| 655 |
+
multicomponent = len(train_data) > 1
|
| 656 |
+
if multicomponent:
|
| 657 |
+
train_dsets = [
|
| 658 |
+
make_dataset(data, args.rxn_mode, args.multi_hot_atom_featurizer_mode)
|
| 659 |
+
for data in train_data
|
| 660 |
+
]
|
| 661 |
+
val_dsets = [
|
| 662 |
+
make_dataset(data, args.rxn_mode, args.multi_hot_atom_featurizer_mode)
|
| 663 |
+
for data in val_data
|
| 664 |
+
]
|
| 665 |
+
train_dset = MulticomponentDataset(train_dsets)
|
| 666 |
+
val_dset = MulticomponentDataset(val_dsets)
|
| 667 |
+
if len(test_data[0]) > 0:
|
| 668 |
+
test_dsets = [
|
| 669 |
+
make_dataset(data, args.rxn_mode, args.multi_hot_atom_featurizer_mode)
|
| 670 |
+
for data in test_data
|
| 671 |
+
]
|
| 672 |
+
test_dset = MulticomponentDataset(test_dsets)
|
| 673 |
+
else:
|
| 674 |
+
test_dset = None
|
| 675 |
+
else:
|
| 676 |
+
train_data = train_data[0]
|
| 677 |
+
val_data = val_data[0]
|
| 678 |
+
test_data = test_data[0]
|
| 679 |
+
|
| 680 |
+
train_dset = make_dataset(train_data, args.rxn_mode, args.multi_hot_atom_featurizer_mode)
|
| 681 |
+
val_dset = make_dataset(val_data, args.rxn_mode, args.multi_hot_atom_featurizer_mode)
|
| 682 |
+
if len(test_data) > 0:
|
| 683 |
+
test_dset = make_dataset(test_data, args.rxn_mode, args.multi_hot_atom_featurizer_mode)
|
| 684 |
+
else:
|
| 685 |
+
test_dset = None
|
| 686 |
+
|
| 687 |
+
return train_dset, val_dset, test_dset
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def build_model(
|
| 691 |
+
args,
|
| 692 |
+
train_dset: MolGraphDataset | MulticomponentDataset,
|
| 693 |
+
output_transform: UnscaleTransform,
|
| 694 |
+
input_transforms: tuple[ScaleTransform, list[GraphTransform], list[ScaleTransform]],
|
| 695 |
+
) -> MPNN:
|
| 696 |
+
mp_cls = AtomMessagePassing if args.atom_messages else BondMessagePassing
|
| 697 |
+
|
| 698 |
+
X_d_transform, graph_transforms, V_d_transforms = input_transforms
|
| 699 |
+
|
| 700 |
+
if isinstance(train_dset, MulticomponentDataset):
|
| 701 |
+
mp_blocks = [
|
| 702 |
+
mp_cls(
|
| 703 |
+
train_dset.datasets[i].featurizer.atom_fdim,
|
| 704 |
+
train_dset.datasets[i].featurizer.bond_fdim,
|
| 705 |
+
d_h=args.message_hidden_dim,
|
| 706 |
+
d_vd=(
|
| 707 |
+
train_dset.datasets[i].d_vd
|
| 708 |
+
if isinstance(train_dset.datasets[i], MoleculeDataset)
|
| 709 |
+
else 0
|
| 710 |
+
),
|
| 711 |
+
bias=args.message_bias,
|
| 712 |
+
depth=args.depth,
|
| 713 |
+
undirected=args.undirected,
|
| 714 |
+
dropout=args.dropout,
|
| 715 |
+
activation=args.activation,
|
| 716 |
+
V_d_transform=V_d_transforms[i],
|
| 717 |
+
graph_transform=graph_transforms[i],
|
| 718 |
+
)
|
| 719 |
+
for i in range(train_dset.n_components)
|
| 720 |
+
]
|
| 721 |
+
if args.mpn_shared:
|
| 722 |
+
if args.reaction_columns is not None and args.smiles_columns is not None:
|
| 723 |
+
raise ArgumentError(
|
| 724 |
+
argument=None,
|
| 725 |
+
message="Cannot use shared MPNN with both molecule and reaction data.",
|
| 726 |
+
)
|
| 727 |
+
|
| 728 |
+
mp_block = MulticomponentMessagePassing(mp_blocks, train_dset.n_components, args.mpn_shared)
|
| 729 |
+
# NOTE(degraff): this if/else block should be handled by the init of MulticomponentMessagePassing
|
| 730 |
+
# if args.mpn_shared:
|
| 731 |
+
# mp_block = MulticomponentMessagePassing(mp_blocks[0], n_components, args.mpn_shared)
|
| 732 |
+
# else:
|
| 733 |
+
d_xd = train_dset.datasets[0].d_xd
|
| 734 |
+
n_tasks = train_dset.datasets[0].Y.shape[1]
|
| 735 |
+
mpnn_cls = MulticomponentMPNN
|
| 736 |
+
else:
|
| 737 |
+
mp_block = mp_cls(
|
| 738 |
+
train_dset.featurizer.atom_fdim,
|
| 739 |
+
train_dset.featurizer.bond_fdim,
|
| 740 |
+
d_h=args.message_hidden_dim,
|
| 741 |
+
d_vd=train_dset.d_vd if isinstance(train_dset, MoleculeDataset) else 0,
|
| 742 |
+
bias=args.message_bias,
|
| 743 |
+
depth=args.depth,
|
| 744 |
+
undirected=args.undirected,
|
| 745 |
+
dropout=args.dropout,
|
| 746 |
+
activation=args.activation,
|
| 747 |
+
V_d_transform=V_d_transforms[0],
|
| 748 |
+
graph_transform=graph_transforms[0],
|
| 749 |
+
)
|
| 750 |
+
d_xd = train_dset.d_xd
|
| 751 |
+
n_tasks = train_dset.Y.shape[1]
|
| 752 |
+
mpnn_cls = MPNN
|
| 753 |
+
|
| 754 |
+
agg = Factory.build(AggregationRegistry[args.aggregation], norm=args.aggregation_norm)
|
| 755 |
+
predictor_cls = PredictorRegistry[args.task_type]
|
| 756 |
+
if args.loss_function is not None:
|
| 757 |
+
criterion = Factory.build(
|
| 758 |
+
LossFunctionRegistry[args.loss_function],
|
| 759 |
+
task_weights=args.task_weights,
|
| 760 |
+
v_kl=args.v_kl,
|
| 761 |
+
# threshold=args.threshold, TODO: Add in v2.1
|
| 762 |
+
eps=args.eps,
|
| 763 |
+
)
|
| 764 |
+
else:
|
| 765 |
+
criterion = None
|
| 766 |
+
if args.metrics is not None:
|
| 767 |
+
metrics = [Factory.build(MetricRegistry[metric]) for metric in args.metrics]
|
| 768 |
+
else:
|
| 769 |
+
metrics = None
|
| 770 |
+
|
| 771 |
+
predictor = Factory.build(
|
| 772 |
+
predictor_cls,
|
| 773 |
+
input_dim=mp_block.output_dim + d_xd,
|
| 774 |
+
n_tasks=n_tasks,
|
| 775 |
+
hidden_dim=args.ffn_hidden_dim,
|
| 776 |
+
n_layers=args.ffn_num_layers,
|
| 777 |
+
dropout=args.dropout,
|
| 778 |
+
activation=args.activation,
|
| 779 |
+
criterion=criterion,
|
| 780 |
+
n_classes=args.multiclass_num_classes,
|
| 781 |
+
output_transform=output_transform,
|
| 782 |
+
# spectral_activation=args.spectral_activation, TODO: Add in v2.1
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
if args.loss_function is None:
|
| 786 |
+
logger.info(
|
| 787 |
+
f"No loss function was specified! Using class default: {predictor_cls._T_default_criterion}"
|
| 788 |
+
)
|
| 789 |
+
|
| 790 |
+
if args.model_frzn is not None:
|
| 791 |
+
model = mpnn_cls.load_from_file(args.model_frzn)
|
| 792 |
+
model.message_passing.apply(lambda module: module.requires_grad_(False))
|
| 793 |
+
model.message_passing.apply(
|
| 794 |
+
lambda m: setattr(m, "p", 0.0) if isinstance(m, torch.nn.Dropout) else None
|
| 795 |
+
)
|
| 796 |
+
model.bn.apply(lambda module: module.requires_grad_(False))
|
| 797 |
+
for idx in range(args.frzn_ffn_layers):
|
| 798 |
+
model.predictor.ffn[idx].requires_grad_(False)
|
| 799 |
+
setattr(model.predictor.ffn[idx + 1][1], "p", 0.0)
|
| 800 |
+
|
| 801 |
+
return model
|
| 802 |
+
|
| 803 |
+
return mpnn_cls(
|
| 804 |
+
mp_block,
|
| 805 |
+
agg,
|
| 806 |
+
predictor,
|
| 807 |
+
not args.no_batch_norm,
|
| 808 |
+
metrics,
|
| 809 |
+
args.warmup_epochs,
|
| 810 |
+
args.init_lr,
|
| 811 |
+
args.max_lr,
|
| 812 |
+
args.final_lr,
|
| 813 |
+
X_d_transform=X_d_transform,
|
| 814 |
+
)
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def train_model(
|
| 818 |
+
args, train_loader, val_loader, test_loader, output_dir, output_transform, input_transforms
|
| 819 |
+
):
|
| 820 |
+
for model_idx in range(args.ensemble_size):
|
| 821 |
+
model_output_dir = output_dir / f"model_{model_idx}"
|
| 822 |
+
model_output_dir.mkdir(exist_ok=True, parents=True)
|
| 823 |
+
|
| 824 |
+
if args.pytorch_seed is None:
|
| 825 |
+
seed = torch.seed()
|
| 826 |
+
deterministic = False
|
| 827 |
+
else:
|
| 828 |
+
seed = args.pytorch_seed + model_idx
|
| 829 |
+
deterministic = True
|
| 830 |
+
|
| 831 |
+
torch.manual_seed(seed)
|
| 832 |
+
|
| 833 |
+
model = build_model(args, train_loader.dataset, output_transform, input_transforms)
|
| 834 |
+
logger.info(model)
|
| 835 |
+
|
| 836 |
+
monitor_mode = "min" if model.metrics[0].minimize else "max"
|
| 837 |
+
logger.debug(f"Evaluation metric: '{model.metrics[0].alias}', mode: '{monitor_mode}'")
|
| 838 |
+
|
| 839 |
+
try:
|
| 840 |
+
trainer_logger = TensorBoardLogger(model_output_dir, "trainer_logs")
|
| 841 |
+
except ModuleNotFoundError:
|
| 842 |
+
trainer_logger = CSVLogger(model_output_dir, "trainer_logs")
|
| 843 |
+
|
| 844 |
+
checkpointing = ModelCheckpoint(
|
| 845 |
+
model_output_dir / "checkpoints",
|
| 846 |
+
"best-{epoch}-{val_loss:.2f}",
|
| 847 |
+
"val_loss",
|
| 848 |
+
mode=monitor_mode,
|
| 849 |
+
save_last=True,
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
patience = args.patience if args.patience is not None else args.epochs
|
| 853 |
+
early_stopping = EarlyStopping("val_loss", patience=patience, mode=monitor_mode)
|
| 854 |
+
|
| 855 |
+
trainer = pl.Trainer(
|
| 856 |
+
logger=trainer_logger,
|
| 857 |
+
enable_progress_bar=True,
|
| 858 |
+
accelerator=args.accelerator,
|
| 859 |
+
devices=args.devices,
|
| 860 |
+
max_epochs=args.epochs,
|
| 861 |
+
callbacks=[checkpointing, early_stopping],
|
| 862 |
+
gradient_clip_val=args.grad_clip,
|
| 863 |
+
deterministic=deterministic,
|
| 864 |
+
)
|
| 865 |
+
trainer.fit(model, train_loader, val_loader)
|
| 866 |
+
|
| 867 |
+
if test_loader is not None:
|
| 868 |
+
predss = trainer.predict(dataloaders=test_loader)
|
| 869 |
+
preds = torch.concat(predss, 0).numpy()
|
| 870 |
+
|
| 871 |
+
if isinstance(test_loader.dataset, MulticomponentDataset):
|
| 872 |
+
test_dset = test_loader.dataset.datasets[0]
|
| 873 |
+
else:
|
| 874 |
+
test_dset = test_loader.dataset
|
| 875 |
+
targets = test_dset.Y
|
| 876 |
+
mask = torch.from_numpy(np.isfinite(targets))
|
| 877 |
+
targets = np.nan_to_num(targets, nan=0.0)
|
| 878 |
+
weights = torch.from_numpy(test_dset.weights)
|
| 879 |
+
lt_mask = (
|
| 880 |
+
torch.from_numpy(test_dset.lt_mask) if test_dset.lt_mask[0] is not None else None
|
| 881 |
+
)
|
| 882 |
+
gt_mask = (
|
| 883 |
+
torch.from_numpy(test_dset.gt_mask) if test_dset.gt_mask[0] is not None else None
|
| 884 |
+
)
|
| 885 |
+
preds_losses = [
|
| 886 |
+
metric(
|
| 887 |
+
torch.from_numpy(preds),
|
| 888 |
+
torch.from_numpy(targets),
|
| 889 |
+
mask,
|
| 890 |
+
weights,
|
| 891 |
+
lt_mask,
|
| 892 |
+
gt_mask,
|
| 893 |
+
)
|
| 894 |
+
for metric in model.metrics
|
| 895 |
+
]
|
| 896 |
+
preds_metrics = {
|
| 897 |
+
f"entire_test/{m.alias}": l.item() for m, l in zip(model.metrics, preds_losses)
|
| 898 |
+
}
|
| 899 |
+
print(f"Entire Test Set results: {preds_metrics}")
|
| 900 |
+
|
| 901 |
+
columns = get_column_names(
|
| 902 |
+
args.data_path,
|
| 903 |
+
args.smiles_columns,
|
| 904 |
+
args.reaction_columns,
|
| 905 |
+
args.target_columns,
|
| 906 |
+
args.ignore_columns,
|
| 907 |
+
args.splits_column,
|
| 908 |
+
args.weight_column,
|
| 909 |
+
args.no_header_row,
|
| 910 |
+
)
|
| 911 |
+
names = test_loader.dataset.names
|
| 912 |
+
if isinstance(test_loader.dataset, MulticomponentDataset):
|
| 913 |
+
namess = list(zip(*names))
|
| 914 |
+
else:
|
| 915 |
+
namess = [names]
|
| 916 |
+
if "multiclass" in args.task_type:
|
| 917 |
+
df_preds = pd.DataFrame(list(zip(*namess, preds)), columns=columns)
|
| 918 |
+
else:
|
| 919 |
+
df_preds = pd.DataFrame(list(zip(*namess, *preds.T)), columns=columns)
|
| 920 |
+
df_preds.to_csv(model_output_dir / "test_predictions.csv", index=False)
|
| 921 |
+
|
| 922 |
+
best_model_path = checkpointing.best_model_path
|
| 923 |
+
model = model.__class__.load_from_checkpoint(best_model_path)
|
| 924 |
+
p_model = model_output_dir / "best.pt"
|
| 925 |
+
save_model(p_model, model)
|
| 926 |
+
logger.info(f"Best model saved to '{p_model}'")
|
| 927 |
+
|
| 928 |
+
|
| 929 |
+
def main(args):
|
| 930 |
+
format_kwargs = dict(
|
| 931 |
+
no_header_row=args.no_header_row,
|
| 932 |
+
smiles_cols=args.smiles_columns,
|
| 933 |
+
rxn_cols=args.reaction_columns,
|
| 934 |
+
target_cols=args.target_columns,
|
| 935 |
+
ignore_cols=args.ignore_columns,
|
| 936 |
+
splits_col=args.splits_column,
|
| 937 |
+
weight_col=args.weight_column,
|
| 938 |
+
bounded=args.loss_function is not None and "bounded" in args.loss_function,
|
| 939 |
+
)
|
| 940 |
+
if args.features_generators is not None:
|
| 941 |
+
# TODO: MorganFeaturizers take radius, length, and include_chirality as arguements. Should we expose these through the CLI?
|
| 942 |
+
features_generators = [
|
| 943 |
+
Factory.build(MoleculeFeaturizerRegistry[features_generator])
|
| 944 |
+
for features_generator in args.features_generators
|
| 945 |
+
]
|
| 946 |
+
else:
|
| 947 |
+
features_generators = None
|
| 948 |
+
|
| 949 |
+
featurization_kwargs = dict(
|
| 950 |
+
features_generators=features_generators, keep_h=args.keep_h, add_h=args.add_h
|
| 951 |
+
)
|
| 952 |
+
|
| 953 |
+
splits = build_splits(args, format_kwargs, featurization_kwargs)
|
| 954 |
+
|
| 955 |
+
for fold_idx, (train_data, val_data, test_data) in enumerate(zip(*splits)):
|
| 956 |
+
if args.num_folds == 1:
|
| 957 |
+
output_dir = args.output_dir
|
| 958 |
+
else:
|
| 959 |
+
output_dir = args.output_dir / f"fold_{fold_idx}"
|
| 960 |
+
|
| 961 |
+
output_dir.mkdir(exist_ok=True, parents=True)
|
| 962 |
+
|
| 963 |
+
train_dset, val_dset, test_dset = build_datasets(args, train_data, val_data, test_data)
|
| 964 |
+
|
| 965 |
+
input_transforms = normalize_inputs(train_dset, val_dset, args)
|
| 966 |
+
|
| 967 |
+
if args.save_smiles_splits:
|
| 968 |
+
save_smiles_splits(args, output_dir, train_dset, val_dset, test_dset)
|
| 969 |
+
|
| 970 |
+
if "regression" in args.task_type:
|
| 971 |
+
output_scaler = train_dset.normalize_targets()
|
| 972 |
+
val_dset.normalize_targets(output_scaler)
|
| 973 |
+
logger.info(f"Train data: mean = {output_scaler.mean_} | std = {output_scaler.scale_}")
|
| 974 |
+
output_transform = UnscaleTransform.from_standard_scaler(output_scaler)
|
| 975 |
+
else:
|
| 976 |
+
output_transform = None
|
| 977 |
+
|
| 978 |
+
train_loader = build_dataloader(
|
| 979 |
+
train_dset, args.batch_size, args.num_workers, seed=args.data_seed
|
| 980 |
+
)
|
| 981 |
+
val_loader = build_dataloader(val_dset, args.batch_size, args.num_workers, shuffle=False)
|
| 982 |
+
if test_dset is not None:
|
| 983 |
+
test_loader = build_dataloader(
|
| 984 |
+
test_dset, args.batch_size, args.num_workers, shuffle=False
|
| 985 |
+
)
|
| 986 |
+
else:
|
| 987 |
+
test_loader = None
|
| 988 |
+
|
| 989 |
+
train_model(
|
| 990 |
+
args,
|
| 991 |
+
train_loader,
|
| 992 |
+
val_loader,
|
| 993 |
+
test_loader,
|
| 994 |
+
output_dir,
|
| 995 |
+
output_transform,
|
| 996 |
+
input_transforms,
|
| 997 |
+
)
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
if __name__ == "__main__":
|
| 1001 |
+
# TODO: update this old code or remove it.
|
| 1002 |
+
parser = ArgumentParser()
|
| 1003 |
+
parser = TrainSubcommand.add_args(parser)
|
| 1004 |
+
|
| 1005 |
+
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)
|
| 1006 |
+
args = parser.parse_args()
|
| 1007 |
+
TrainSubcommand.func(args)
|
chemprop/cli/utils/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .args import bounded
|
| 2 |
+
from .actions import LookupAction
|
| 3 |
+
from .command import Subcommand
|
| 4 |
+
from .parsing import (
|
| 5 |
+
build_data_from_files,
|
| 6 |
+
make_datapoints,
|
| 7 |
+
make_dataset,
|
| 8 |
+
get_column_names,
|
| 9 |
+
parse_indices,
|
| 10 |
+
)
|
| 11 |
+
from .utils import pop_attr, _pop_attr, _pop_attr_d, validate_loss_function
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"bounded",
|
| 15 |
+
"LookupAction",
|
| 16 |
+
"Subcommand",
|
| 17 |
+
"build_data_from_files",
|
| 18 |
+
"make_datapoints",
|
| 19 |
+
"make_dataset",
|
| 20 |
+
"get_column_names",
|
| 21 |
+
"parse_indices",
|
| 22 |
+
"actions",
|
| 23 |
+
"args",
|
| 24 |
+
"command",
|
| 25 |
+
"parsing",
|
| 26 |
+
"utils",
|
| 27 |
+
"pop_attr",
|
| 28 |
+
"_pop_attr",
|
| 29 |
+
"_pop_attr_d",
|
| 30 |
+
"validate_loss_function",
|
| 31 |
+
]
|
chemprop/cli/utils/actions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from argparse import Action, ArgumentParser, Namespace
|
| 2 |
+
from typing import Any, Mapping, Sequence
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def LookupAction(obj: Mapping[str, Any]):
|
| 6 |
+
class LookupAction_(Action):
|
| 7 |
+
def __init__(self, option_strings, dest, default=None, choices=None, **kwargs):
|
| 8 |
+
if default not in obj.keys() and default is not None:
|
| 9 |
+
raise ValueError(
|
| 10 |
+
f"Invalid value for arg 'default': '{default}'. "
|
| 11 |
+
f"Expected one of {tuple(obj.keys())}"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
kwargs["choices"] = choices if choices is not None else obj.keys()
|
| 15 |
+
kwargs["default"] = default
|
| 16 |
+
|
| 17 |
+
super().__init__(option_strings, dest, **kwargs)
|
| 18 |
+
|
| 19 |
+
def __call__(
|
| 20 |
+
self,
|
| 21 |
+
parser: ArgumentParser,
|
| 22 |
+
namespace: Namespace,
|
| 23 |
+
values: str | Sequence[Any] | None,
|
| 24 |
+
option_string: str | None = None,
|
| 25 |
+
):
|
| 26 |
+
setattr(namespace, self.dest, values)
|
| 27 |
+
|
| 28 |
+
return LookupAction_
|
chemprop/cli/utils/args.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import functools
|
| 2 |
+
|
| 3 |
+
__all__ = ["bounded"]
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def bounded(lo: float | None = None, hi: float | None = None):
|
| 7 |
+
if lo is None and hi is None:
|
| 8 |
+
raise ValueError("No bounds provided!")
|
| 9 |
+
|
| 10 |
+
def decorator(f):
|
| 11 |
+
@functools.wraps(f)
|
| 12 |
+
def wrapper(*args, **kwargs):
|
| 13 |
+
x = f(*args, **kwargs)
|
| 14 |
+
|
| 15 |
+
if (lo is not None and hi is not None) and not lo <= x <= hi:
|
| 16 |
+
raise ValueError(f"Parsed value outside of range [{lo}, {hi}]! got: {x}")
|
| 17 |
+
if hi is not None and x > hi:
|
| 18 |
+
raise ValueError(f"Parsed value below {hi}! got: {x}")
|
| 19 |
+
if lo is not None and x < lo:
|
| 20 |
+
raise ValueError(f"Parsed value above {lo}]! got: {x}")
|
| 21 |
+
|
| 22 |
+
return x
|
| 23 |
+
|
| 24 |
+
return wrapper
|
| 25 |
+
|
| 26 |
+
return decorator
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def uppercase(x: str):
|
| 30 |
+
return x.upper()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def lowercase(x: str):
|
| 34 |
+
return x.lower()
|
chemprop/cli/utils/command.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import ABC, abstractmethod
|
| 2 |
+
from argparse import ArgumentParser, _SubParsersAction, Namespace
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Subcommand(ABC):
|
| 6 |
+
COMMAND: str
|
| 7 |
+
HELP: str | None = None
|
| 8 |
+
|
| 9 |
+
@classmethod
|
| 10 |
+
def add(cls, subparsers: _SubParsersAction, parents) -> ArgumentParser:
|
| 11 |
+
parser = subparsers.add_parser(cls.COMMAND, help=cls.HELP, parents=parents)
|
| 12 |
+
cls.add_args(parser).set_defaults(func=cls.func)
|
| 13 |
+
|
| 14 |
+
return parser
|
| 15 |
+
|
| 16 |
+
@classmethod
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def add_args(cls, parser: ArgumentParser) -> ArgumentParser:
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
@classmethod
|
| 22 |
+
@abstractmethod
|
| 23 |
+
def func(cls, args: Namespace):
|
| 24 |
+
pass
|
chemprop/cli/utils/parsing.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from os import PathLike
|
| 3 |
+
from typing import Mapping, Sequence
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from rdkit.Chem import Mol
|
| 8 |
+
|
| 9 |
+
from chemprop.data.datapoints import MoleculeDatapoint, ReactionDatapoint
|
| 10 |
+
from chemprop.data.datasets import MoleculeDataset, ReactionDataset
|
| 11 |
+
from chemprop.featurizers.base import VectorFeaturizer
|
| 12 |
+
from chemprop.featurizers.molgraph import (
|
| 13 |
+
CondensedGraphOfReactionFeaturizer,
|
| 14 |
+
SimpleMoleculeMolGraphFeaturizer,
|
| 15 |
+
)
|
| 16 |
+
from chemprop.featurizers.atom import get_multi_hot_atom_featurizer
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_csv(
|
| 22 |
+
path: PathLike,
|
| 23 |
+
smiles_cols: Sequence[str] | None,
|
| 24 |
+
rxn_cols: Sequence[str] | None,
|
| 25 |
+
target_cols: Sequence[str] | None,
|
| 26 |
+
ignore_cols: Sequence[str] | None,
|
| 27 |
+
splits_col: str | None,
|
| 28 |
+
weight_col: str | None,
|
| 29 |
+
bounded: bool = False,
|
| 30 |
+
no_header_row: bool = False,
|
| 31 |
+
):
|
| 32 |
+
df = pd.read_csv(path, header=None if no_header_row else "infer", index_col=False)
|
| 33 |
+
|
| 34 |
+
if smiles_cols is not None and rxn_cols is not None:
|
| 35 |
+
smiss = df[smiles_cols].T.values.tolist()
|
| 36 |
+
rxnss = df[rxn_cols].T.values.tolist()
|
| 37 |
+
input_cols = [*smiles_cols, *rxn_cols]
|
| 38 |
+
elif smiles_cols is not None and rxn_cols is None:
|
| 39 |
+
smiss = df[smiles_cols].T.values.tolist()
|
| 40 |
+
rxnss = None
|
| 41 |
+
input_cols = smiles_cols
|
| 42 |
+
elif smiles_cols is None and rxn_cols is not None:
|
| 43 |
+
smiss = None
|
| 44 |
+
rxnss = df[rxn_cols].T.values.tolist()
|
| 45 |
+
input_cols = rxn_cols
|
| 46 |
+
else:
|
| 47 |
+
smiss = df.iloc[:, [0]].T.values.tolist()
|
| 48 |
+
rxnss = None
|
| 49 |
+
input_cols = [df.columns[0]]
|
| 50 |
+
|
| 51 |
+
if target_cols is None:
|
| 52 |
+
target_cols = list(
|
| 53 |
+
set(df.columns)
|
| 54 |
+
- set(input_cols)
|
| 55 |
+
- set(ignore_cols or [])
|
| 56 |
+
- set(splits_col or [])
|
| 57 |
+
- set(weight_col or [])
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
Y = df[target_cols]
|
| 61 |
+
weights = None if weight_col is None else df[weight_col].to_numpy(np.single)
|
| 62 |
+
|
| 63 |
+
if bounded:
|
| 64 |
+
lt_mask = Y.applymap(lambda x: "<" in x).to_numpy()
|
| 65 |
+
gt_mask = Y.applymap(lambda x: ">" in x).to_numpy()
|
| 66 |
+
Y = Y.applymap(lambda x: x.strip("<").strip(">")).to_numpy(np.single)
|
| 67 |
+
else:
|
| 68 |
+
Y = Y.to_numpy(np.single)
|
| 69 |
+
lt_mask = None
|
| 70 |
+
gt_mask = None
|
| 71 |
+
|
| 72 |
+
return smiss, rxnss, Y, weights, lt_mask, gt_mask
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def get_column_names(
|
| 76 |
+
path: PathLike,
|
| 77 |
+
smiles_cols: Sequence[str] | None,
|
| 78 |
+
rxn_cols: Sequence[str] | None,
|
| 79 |
+
target_cols: Sequence[str] | None,
|
| 80 |
+
ignore_cols: Sequence[str] | None,
|
| 81 |
+
splits_col: str | None,
|
| 82 |
+
weight_col: str | None,
|
| 83 |
+
no_header_row: bool = False,
|
| 84 |
+
):
|
| 85 |
+
df = pd.read_csv(path, header=None if no_header_row else "infer", index_col=False)
|
| 86 |
+
|
| 87 |
+
if no_header_row:
|
| 88 |
+
return ["SMILES"] + ["pred_" + str(i) for i in range((len(df.columns) - 1))]
|
| 89 |
+
|
| 90 |
+
input_cols = (smiles_cols or []) + (rxn_cols or [])
|
| 91 |
+
|
| 92 |
+
if len(input_cols) == 0:
|
| 93 |
+
input_cols = [df.columns[0]]
|
| 94 |
+
|
| 95 |
+
if target_cols is None:
|
| 96 |
+
target_cols = list(
|
| 97 |
+
set(df.columns)
|
| 98 |
+
- set(input_cols)
|
| 99 |
+
- set(ignore_cols or [])
|
| 100 |
+
- set(splits_col or [])
|
| 101 |
+
- set(weight_col or [])
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
return input_cols + target_cols
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def make_datapoints(
|
| 108 |
+
smiss: list[list[str]] | None,
|
| 109 |
+
rxnss: list[list[str]] | None,
|
| 110 |
+
Y: np.ndarray,
|
| 111 |
+
weights: np.ndarray | None,
|
| 112 |
+
lt_mask: np.ndarray | None,
|
| 113 |
+
gt_mask: np.ndarray | None,
|
| 114 |
+
X_d: np.ndarray | None,
|
| 115 |
+
V_fss: list[list[np.ndarray] | list[None]] | None,
|
| 116 |
+
E_fss: list[list[np.ndarray] | list[None]] | None,
|
| 117 |
+
V_dss: list[list[np.ndarray] | list[None]] | None,
|
| 118 |
+
features_generators: list[VectorFeaturizer[Mol]] | None,
|
| 119 |
+
keep_h: bool,
|
| 120 |
+
add_h: bool,
|
| 121 |
+
) -> tuple[list[list[MoleculeDatapoint]], list[list[ReactionDatapoint]]]:
|
| 122 |
+
"""Make the :class:`MoleculeDatapoint`s and :class:`ReactionDatapoint`s for a given
|
| 123 |
+
dataset.
|
| 124 |
+
|
| 125 |
+
Parameters
|
| 126 |
+
----------
|
| 127 |
+
smiss : list[list[str]] | None
|
| 128 |
+
a list of ``j`` lists of ``n`` SMILES strings, where ``j`` is the number of molecules per
|
| 129 |
+
datapoint and ``n`` is the number of datapoints. If ``None``, the corresponding list of
|
| 130 |
+
:class:`MoleculeDatapoint`\s will be empty.
|
| 131 |
+
rxnss : list[list[str]] | None
|
| 132 |
+
a list of ``k`` lists of ``n`` reaction SMILES strings, where ``k`` is the number of
|
| 133 |
+
reactions per datapoint. If ``None``, the corresponding list of :class:`ReactionDatapoint`\s
|
| 134 |
+
will be empty.
|
| 135 |
+
Y : np.ndarray
|
| 136 |
+
the target values of shape ``n x m``, where ``m`` is the number of targets
|
| 137 |
+
weights : np.ndarray | None
|
| 138 |
+
the weights of the datapoints to use in the loss function of shape ``n x m``. If ``None``,
|
| 139 |
+
the weights all default to 1.
|
| 140 |
+
lt_mask : np.ndarray | None
|
| 141 |
+
a boolean mask of shape ``n x m`` indicating whether the targets are less than inequality
|
| 142 |
+
targets. If ``None``, ``lt_mask`` for all datapoints will be ``None``.
|
| 143 |
+
gt_mask : np.ndarray | None
|
| 144 |
+
a boolean mask of shape ``n x m`` indicating whether the targets are greater than inequality
|
| 145 |
+
targets. If ``None``, ``gt_mask`` for all datapoints will be ``None``.
|
| 146 |
+
X_d : np.ndarray | None
|
| 147 |
+
the extra descriptors of shape ``n x p``, where ``p`` is the number of extra descriptors. If
|
| 148 |
+
``None``, ``x_d`` for all datapoints will be ``None``.
|
| 149 |
+
V_fss : list[list[np.ndarray] | list[None]] | None
|
| 150 |
+
a list of ``j`` lists of ``n`` np.ndarrays each of shape ``v_jn x q_j``, where ``v_jn`` is
|
| 151 |
+
the number of atoms in the j-th molecule of the n-th datapoint and ``q_j`` is the number of
|
| 152 |
+
extra atom features used for the j-th molecules. Any of the ``j`` lists can be a list of
|
| 153 |
+
None values if the corresponding component does not use extra atom features. If ``None``,
|
| 154 |
+
``V_f`` for all datapoints will be ``None``.
|
| 155 |
+
E_fss : list[list[np.ndarray] | list[None]] | None
|
| 156 |
+
a list of ``j`` lists of ``n`` np.ndarrays each of shape ``e_jn x r_j``, where ``e_jn`` is
|
| 157 |
+
the number of bonds in the j-th molecule of the n-th datapoint and ``r_j`` is the number of
|
| 158 |
+
extra bond features used for the j-th molecules. Any of the ``j`` lists can be a list of
|
| 159 |
+
None values if the corresponding component does not use extra bond features. If ``None``,
|
| 160 |
+
``E_f`` for all datapoints will be ``None``.
|
| 161 |
+
V_dss : list[list[np.ndarray] | list[None]] | None
|
| 162 |
+
a list of ``j`` lists of ``n`` np.ndarrays each of shape ``v_jn x s_j``, where ``s_j`` is
|
| 163 |
+
the number of extra atom descriptors used for the j-th molecules. Any of the ``j`` lists can
|
| 164 |
+
be a list of None values if the corresponding component does not use extra atom features. If
|
| 165 |
+
``None``, ``V_d`` for all datapoints will be ``None``.
|
| 166 |
+
features_generators : list[MoleculeFeaturizer] | None
|
| 167 |
+
a list of :class:`MoleculeFeaturizer` instances to generate additional molecule features to
|
| 168 |
+
use as extra descriptors
|
| 169 |
+
keep_h : bool
|
| 170 |
+
add_h : bool
|
| 171 |
+
|
| 172 |
+
Returns
|
| 173 |
+
-------
|
| 174 |
+
list[list[MoleculeDatapoint]]
|
| 175 |
+
a list of ``j`` lists of ``n`` :class:`MoleculeDatapoint`\s
|
| 176 |
+
list[list[ReactionDatapoint]]
|
| 177 |
+
a list of ``k`` lists of ``n`` :class:`ReactionDatapoint`\s
|
| 178 |
+
.. note::
|
| 179 |
+
either ``j`` or ``k`` may be 0, in which case the corresponding list will be empty.
|
| 180 |
+
|
| 181 |
+
Raises
|
| 182 |
+
------
|
| 183 |
+
ValueError
|
| 184 |
+
if both ``smiss`` and ``rxnss`` are ``None``.
|
| 185 |
+
if ``smiss`` and ``rxnss`` are both given and have different lengths.
|
| 186 |
+
"""
|
| 187 |
+
if smiss is None and rxnss is None:
|
| 188 |
+
raise ValueError("args 'smiss' and 'rnxss' were both `None`!")
|
| 189 |
+
elif rxnss is None:
|
| 190 |
+
N = len(smiss[0])
|
| 191 |
+
rxnss = []
|
| 192 |
+
elif smiss is None:
|
| 193 |
+
N = len(rxnss[0])
|
| 194 |
+
smiss = []
|
| 195 |
+
elif len(smiss[0]) != len(rxnss[0]):
|
| 196 |
+
raise ValueError(
|
| 197 |
+
f"args 'smiss' and 'rxnss' must have same length! got {len(smiss[0])} and {len(rxnss[0])}"
|
| 198 |
+
)
|
| 199 |
+
else:
|
| 200 |
+
N = len(smiss[0])
|
| 201 |
+
|
| 202 |
+
weights = np.ones(N, dtype=np.single) if weights is None else weights
|
| 203 |
+
gt_mask = [None] * N if gt_mask is None else gt_mask
|
| 204 |
+
lt_mask = [None] * N if lt_mask is None else lt_mask
|
| 205 |
+
|
| 206 |
+
n_mols = len(smiss) if smiss else 0
|
| 207 |
+
X_d = [None] * N if X_d is None else X_d
|
| 208 |
+
V_fss = [[None] * N] * n_mols if V_fss is None else V_fss
|
| 209 |
+
E_fss = [[None] * N] * n_mols if E_fss is None else E_fss
|
| 210 |
+
V_dss = [[None] * N] * n_mols if V_dss is None else V_dss
|
| 211 |
+
|
| 212 |
+
mol_data = [
|
| 213 |
+
[
|
| 214 |
+
MoleculeDatapoint.from_smi(
|
| 215 |
+
smis[i],
|
| 216 |
+
keep_h=keep_h,
|
| 217 |
+
add_h=add_h,
|
| 218 |
+
y=Y[i],
|
| 219 |
+
weight=weights[i],
|
| 220 |
+
gt_mask=gt_mask[i],
|
| 221 |
+
lt_mask=lt_mask[i],
|
| 222 |
+
x_d=X_d[i],
|
| 223 |
+
mfs=features_generators,
|
| 224 |
+
x_phase=None,
|
| 225 |
+
V_f=V_fss[mol_idx][i],
|
| 226 |
+
E_f=E_fss[mol_idx][i],
|
| 227 |
+
V_d=V_dss[mol_idx][i],
|
| 228 |
+
)
|
| 229 |
+
for i in range(N)
|
| 230 |
+
]
|
| 231 |
+
for mol_idx, smis in enumerate(smiss)
|
| 232 |
+
]
|
| 233 |
+
rxn_data = [
|
| 234 |
+
[
|
| 235 |
+
ReactionDatapoint.from_smi(
|
| 236 |
+
rxns[i],
|
| 237 |
+
keep_h=keep_h,
|
| 238 |
+
add_h=add_h,
|
| 239 |
+
y=Y[i],
|
| 240 |
+
weight=weights[i],
|
| 241 |
+
gt_mask=gt_mask[i],
|
| 242 |
+
lt_mask=lt_mask[i],
|
| 243 |
+
x_d=X_d[i],
|
| 244 |
+
mfs=features_generators,
|
| 245 |
+
x_phase=None,
|
| 246 |
+
)
|
| 247 |
+
for i in range(N)
|
| 248 |
+
]
|
| 249 |
+
for rxn_idx, rxns in enumerate(rxnss)
|
| 250 |
+
]
|
| 251 |
+
|
| 252 |
+
return mol_data, rxn_data
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def build_data_from_files(
|
| 256 |
+
p_data: PathLike,
|
| 257 |
+
no_header_row: bool,
|
| 258 |
+
smiles_cols: Sequence[str] | None,
|
| 259 |
+
rxn_cols: Sequence[str] | None,
|
| 260 |
+
target_cols: Sequence[str] | None,
|
| 261 |
+
ignore_cols: Sequence[str] | None,
|
| 262 |
+
splits_col: str | None,
|
| 263 |
+
weight_col: str | None,
|
| 264 |
+
bounded: bool,
|
| 265 |
+
p_descriptors: PathLike,
|
| 266 |
+
p_atom_feats: dict[int, PathLike],
|
| 267 |
+
p_bond_feats: dict[int, PathLike],
|
| 268 |
+
p_atom_descs: dict[int, PathLike],
|
| 269 |
+
**featurization_kwargs: Mapping,
|
| 270 |
+
) -> list[list[MoleculeDatapoint] | list[ReactionDatapoint]]:
|
| 271 |
+
smiss, rxnss, Y, weights, lt_mask, gt_mask = parse_csv(
|
| 272 |
+
p_data,
|
| 273 |
+
smiles_cols,
|
| 274 |
+
rxn_cols,
|
| 275 |
+
target_cols,
|
| 276 |
+
ignore_cols,
|
| 277 |
+
splits_col,
|
| 278 |
+
weight_col,
|
| 279 |
+
bounded,
|
| 280 |
+
no_header_row,
|
| 281 |
+
)
|
| 282 |
+
n_molecules = len(smiss) if smiss is not None else 0
|
| 283 |
+
n_datapoints = len(Y)
|
| 284 |
+
|
| 285 |
+
X_ds = load_input_feats_and_descs(p_descriptors, None, None, feat_desc="X_d")
|
| 286 |
+
V_fss = load_input_feats_and_descs(p_atom_feats, n_molecules, n_datapoints, feat_desc="V_f")
|
| 287 |
+
E_fss = load_input_feats_and_descs(p_bond_feats, n_molecules, n_datapoints, feat_desc="E_f")
|
| 288 |
+
V_dss = load_input_feats_and_descs(p_atom_descs, n_molecules, n_datapoints, feat_desc="V_d")
|
| 289 |
+
|
| 290 |
+
mol_data, rxn_data = make_datapoints(
|
| 291 |
+
smiss,
|
| 292 |
+
rxnss,
|
| 293 |
+
Y,
|
| 294 |
+
weights,
|
| 295 |
+
lt_mask,
|
| 296 |
+
gt_mask,
|
| 297 |
+
X_ds,
|
| 298 |
+
V_fss,
|
| 299 |
+
E_fss,
|
| 300 |
+
V_dss,
|
| 301 |
+
**featurization_kwargs,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
return mol_data + rxn_data
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def load_input_feats_and_descs(
|
| 308 |
+
paths: dict[int, PathLike] | PathLike,
|
| 309 |
+
n_molecules: int | None,
|
| 310 |
+
n_datapoints: int | None,
|
| 311 |
+
feat_desc: str,
|
| 312 |
+
):
|
| 313 |
+
if paths is None:
|
| 314 |
+
return None
|
| 315 |
+
|
| 316 |
+
match feat_desc:
|
| 317 |
+
case "X_d":
|
| 318 |
+
path = paths
|
| 319 |
+
loaded_feature = np.load(path)
|
| 320 |
+
features = loaded_feature["arr_0"]
|
| 321 |
+
|
| 322 |
+
case _:
|
| 323 |
+
for index in paths:
|
| 324 |
+
if index >= n_molecules:
|
| 325 |
+
raise ValueError(
|
| 326 |
+
f"For {n_molecules} molecules, atom/bond features/descriptors can only be specified for indices 0-{n_molecules - 1}! Got index {index}."
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
features = []
|
| 330 |
+
for idx in range(n_molecules):
|
| 331 |
+
path = paths.get(idx, None)
|
| 332 |
+
|
| 333 |
+
if path is not None:
|
| 334 |
+
loaded_feature = np.load(path)
|
| 335 |
+
loaded_feature = [
|
| 336 |
+
loaded_feature[f"arr_{i}"] for i in range(len(loaded_feature))
|
| 337 |
+
]
|
| 338 |
+
else:
|
| 339 |
+
loaded_feature = [None] * n_datapoints
|
| 340 |
+
|
| 341 |
+
features.append(loaded_feature)
|
| 342 |
+
return features
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def make_dataset(
|
| 346 |
+
data: Sequence[MoleculeDatapoint] | Sequence[ReactionDatapoint],
|
| 347 |
+
reaction_mode: str,
|
| 348 |
+
multi_hot_atom_featurizer_mode: str = "V2",
|
| 349 |
+
) -> MoleculeDataset | ReactionDataset:
|
| 350 |
+
atom_featurizer = get_multi_hot_atom_featurizer(multi_hot_atom_featurizer_mode)
|
| 351 |
+
|
| 352 |
+
if isinstance(data[0], MoleculeDatapoint):
|
| 353 |
+
extra_atom_fdim = data[0].V_f.shape[1] if data[0].V_f is not None else 0
|
| 354 |
+
extra_bond_fdim = data[0].E_f.shape[1] if data[0].E_f is not None else 0
|
| 355 |
+
featurizer = SimpleMoleculeMolGraphFeaturizer(
|
| 356 |
+
atom_featurizer=atom_featurizer,
|
| 357 |
+
extra_atom_fdim=extra_atom_fdim,
|
| 358 |
+
extra_bond_fdim=extra_bond_fdim,
|
| 359 |
+
)
|
| 360 |
+
return MoleculeDataset(data, featurizer)
|
| 361 |
+
|
| 362 |
+
featurizer = CondensedGraphOfReactionFeaturizer(
|
| 363 |
+
mode_=reaction_mode, atom_featurizer=atom_featurizer
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
return ReactionDataset(data, featurizer)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def parse_indices(idxs):
|
| 370 |
+
"""Parses a string of indices into a list of integers. e.g. '0,1,2-4' -> [0, 1, 2, 3, 4]"""
|
| 371 |
+
if isinstance(idxs, str):
|
| 372 |
+
indices = []
|
| 373 |
+
for idx in idxs.split(","):
|
| 374 |
+
if "-" in idx:
|
| 375 |
+
start, end = map(int, idx.split("-"))
|
| 376 |
+
indices.extend(range(start, end + 1))
|
| 377 |
+
else:
|
| 378 |
+
indices.append(int(idx))
|
| 379 |
+
return indices
|
| 380 |
+
return idxs
|
chemprop/cli/utils/utils.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Type
|
| 2 |
+
|
| 3 |
+
from chemprop.nn import loss, predictors
|
| 4 |
+
|
| 5 |
+
__all__ = ["pop_attr"]
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def pop_attr(o: object, attr: str, *args) -> Any | None:
|
| 9 |
+
"""like ``pop()`` but for attribute maps"""
|
| 10 |
+
match len(args):
|
| 11 |
+
case 0:
|
| 12 |
+
return _pop_attr(o, attr)
|
| 13 |
+
case 1:
|
| 14 |
+
return _pop_attr_d(o, attr, args[0])
|
| 15 |
+
case _:
|
| 16 |
+
raise TypeError(f"Expected at most 2 arguments! got: {len(args)}")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _pop_attr(o: object, attr: str) -> Any:
|
| 20 |
+
val = getattr(o, attr)
|
| 21 |
+
delattr(o, attr)
|
| 22 |
+
|
| 23 |
+
return val
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _pop_attr_d(o: object, attr: str, default: Any | None = None) -> Any | None:
|
| 27 |
+
try:
|
| 28 |
+
val = getattr(o, attr)
|
| 29 |
+
delattr(o, attr)
|
| 30 |
+
except AttributeError:
|
| 31 |
+
val = default
|
| 32 |
+
|
| 33 |
+
return val
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def validate_loss_function(
|
| 37 |
+
predictor_ffn: Type[predictors._FFNPredictorBase], criterion: Type[loss.LossFunction]
|
| 38 |
+
):
|
| 39 |
+
match predictor_ffn:
|
| 40 |
+
case predictors.RegressionFFN:
|
| 41 |
+
if criterion not in (loss.MSELoss, loss.BoundedMSELoss):
|
| 42 |
+
raise ValueError(f"Expected a regression loss function! got: {criterion.__name__}")
|
| 43 |
+
case predictors.MveFFN:
|
| 44 |
+
if criterion is not loss.MVELoss:
|
| 45 |
+
raise ValueError(f"Expected a MVE loss function! got: {criterion.__name__}")
|
| 46 |
+
case predictors.EvidentialFFN:
|
| 47 |
+
if criterion is not loss.EvidentialLoss:
|
| 48 |
+
raise ValueError(f"Expected an evidential loss function! got: {criterion.__name__}")
|
| 49 |
+
case predictors.BinaryClassificationFFN:
|
| 50 |
+
if criterion not in (loss.BCELoss, loss.BinaryMCCLoss):
|
| 51 |
+
raise ValueError(
|
| 52 |
+
f"Expected a binary classification loss function! got: {criterion.__name__}"
|
| 53 |
+
)
|
| 54 |
+
case predictors.BinaryDirichletFFN:
|
| 55 |
+
if loss is not loss.BinaryDirichletLoss:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
f"Expected a binary Dirichlet loss function! got: {criterion.__name__}"
|
| 58 |
+
)
|
| 59 |
+
case predictors.MulticlassClassificationFFN:
|
| 60 |
+
if loss not in (loss.CrossEntropyLoss, loss.MulticlassMCCLoss):
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"Expected a multiclass classification loss function! got: {criterion.__name__}"
|
| 63 |
+
)
|
| 64 |
+
case predictors.MulticlassDirichletFFN:
|
| 65 |
+
if loss is not loss.MulticlassDirichletLoss:
|
| 66 |
+
raise ValueError(
|
| 67 |
+
f"Expected a multiclass Dirichlet loss function! got: {criterion.__name__}"
|
| 68 |
+
)
|
| 69 |
+
case predictors.SpectralFFN:
|
| 70 |
+
if loss not in (loss.SIDLoss, loss.WassersteinLoss):
|
| 71 |
+
raise ValueError(f"Expected a spectral loss function! got: {criterion.__name__}")
|
| 72 |
+
case _:
|
| 73 |
+
raise ValueError(
|
| 74 |
+
f"Unknown predictor function! got: {predictor_ffn}. "
|
| 75 |
+
f"Expected one of: {tuple(predictors.PredictorRegistry.values())}"
|
| 76 |
+
)
|
chemprop/conf.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Global configuration variables for chemprop"""
|
| 2 |
+
|
| 3 |
+
from chemprop.featurizers.molgraph.molecule import SimpleMoleculeMolGraphFeaturizer
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
DEFAULT_ATOM_FDIM, DEFAULT_BOND_FDIM = SimpleMoleculeMolGraphFeaturizer().shape
|
| 7 |
+
DEFAULT_HIDDEN_DIM = 300
|
chemprop/data/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .collate import BatchMolGraph, TrainingBatch, collate_batch, collate_multicomponent
|
| 2 |
+
from .dataloader import build_dataloader
|
| 3 |
+
from .datapoints import MoleculeDatapoint, ReactionDatapoint
|
| 4 |
+
from .datasets import (
|
| 5 |
+
MoleculeDataset,
|
| 6 |
+
ReactionDataset,
|
| 7 |
+
Datum,
|
| 8 |
+
MulticomponentDataset,
|
| 9 |
+
MolGraphDataset,
|
| 10 |
+
)
|
| 11 |
+
from .molgraph import MolGraph
|
| 12 |
+
from .samplers import ClassBalanceSampler, SeededSampler
|
| 13 |
+
from .splitting import SplitType, make_split_indices, split_data_by_indices
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"BatchMolGraph",
|
| 17 |
+
"TrainingBatch",
|
| 18 |
+
"collate_batch",
|
| 19 |
+
"collate_multicomponent",
|
| 20 |
+
"build_dataloader",
|
| 21 |
+
"MoleculeDatapoint",
|
| 22 |
+
"ReactionDatapoint",
|
| 23 |
+
"MoleculeDataset",
|
| 24 |
+
"ReactionDataset",
|
| 25 |
+
"Datum",
|
| 26 |
+
"MulticomponentDataset",
|
| 27 |
+
"MolGraphDataset",
|
| 28 |
+
"MolGraph",
|
| 29 |
+
"ClassBalanceSampler",
|
| 30 |
+
"SeededSampler",
|
| 31 |
+
"SplitType",
|
| 32 |
+
"make_split_indices",
|
| 33 |
+
"split_data_by_indices",
|
| 34 |
+
]
|
chemprop/data/collate.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field, InitVar
|
| 2 |
+
from typing import Iterable, NamedTuple, Sequence
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
from torch import Tensor
|
| 7 |
+
|
| 8 |
+
from chemprop.data.datasets import Datum
|
| 9 |
+
from chemprop.data.molgraph import MolGraph
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(repr=False, eq=False, slots=True)
|
| 13 |
+
class BatchMolGraph:
|
| 14 |
+
"""A :class:`BatchMolGraph` represents a batch of individual :class:`MolGraph`\s.
|
| 15 |
+
|
| 16 |
+
It has all the attributes of a ``MolGraph`` with the addition of the ``batch`` attribute. This
|
| 17 |
+
class is intended for use with data loading, so it uses :obj:`~torch.Tensor`\s to store data
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
mgs: InitVar[Sequence[MolGraph]]
|
| 21 |
+
"""A list of individual :class:`MolGraph`\s to be batched together"""
|
| 22 |
+
V: Tensor = field(init=False)
|
| 23 |
+
"""the atom feature matrix"""
|
| 24 |
+
E: Tensor = field(init=False)
|
| 25 |
+
"""the bond feature matrix"""
|
| 26 |
+
edge_index: Tensor = field(init=False)
|
| 27 |
+
"""an tensor of shape ``2 x E`` containing the edges of the graph in COO format"""
|
| 28 |
+
rev_edge_index: Tensor = field(init=False)
|
| 29 |
+
"""A tensor of shape ``E`` that maps from an edge index to the index of the source of the
|
| 30 |
+
reverse edge in the ``edge_index`` attribute."""
|
| 31 |
+
batch: Tensor = field(init=False)
|
| 32 |
+
"""the index of the parent :class:`MolGraph` in the batched graph"""
|
| 33 |
+
|
| 34 |
+
__size: int = field(init=False)
|
| 35 |
+
|
| 36 |
+
def __post_init__(self, mgs: Sequence[MolGraph]):
|
| 37 |
+
self.__size = len(mgs)
|
| 38 |
+
|
| 39 |
+
Vs = []
|
| 40 |
+
Es = []
|
| 41 |
+
edge_indexes = []
|
| 42 |
+
rev_edge_indexes = []
|
| 43 |
+
batch_indexes = []
|
| 44 |
+
|
| 45 |
+
num_nodes = 0
|
| 46 |
+
num_edges = 0
|
| 47 |
+
for i, mg in enumerate(mgs):
|
| 48 |
+
Vs.append(mg.V)
|
| 49 |
+
Es.append(mg.E)
|
| 50 |
+
edge_indexes.append(mg.edge_index + num_nodes)
|
| 51 |
+
rev_edge_indexes.append(mg.rev_edge_index + num_edges)
|
| 52 |
+
batch_indexes.append([i] * len(mg.V))
|
| 53 |
+
|
| 54 |
+
num_nodes += mg.V.shape[0]
|
| 55 |
+
num_edges += mg.edge_index.shape[1]
|
| 56 |
+
|
| 57 |
+
self.V = torch.from_numpy(np.concatenate(Vs)).float()
|
| 58 |
+
self.E = torch.from_numpy(np.concatenate(Es)).float()
|
| 59 |
+
self.edge_index = torch.from_numpy(np.hstack(edge_indexes)).long()
|
| 60 |
+
self.rev_edge_index = torch.from_numpy(np.concatenate(rev_edge_indexes)).long()
|
| 61 |
+
self.batch = torch.tensor(np.concatenate(batch_indexes)).long()
|
| 62 |
+
|
| 63 |
+
def __len__(self) -> int:
|
| 64 |
+
"""the number of individual :class:`MolGraph`\s in this batch"""
|
| 65 |
+
return self.__size
|
| 66 |
+
|
| 67 |
+
def to(self, device: str | torch.device):
|
| 68 |
+
self.V = self.V.to(device)
|
| 69 |
+
self.E = self.E.to(device)
|
| 70 |
+
self.edge_index = self.edge_index.to(device)
|
| 71 |
+
self.rev_edge_index = self.rev_edge_index.to(device)
|
| 72 |
+
self.batch = self.batch.to(device)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class TrainingBatch(NamedTuple):
|
| 76 |
+
bmg: BatchMolGraph
|
| 77 |
+
V_d: Tensor | None
|
| 78 |
+
X_d: Tensor | None
|
| 79 |
+
Y: Tensor | None
|
| 80 |
+
w: Tensor
|
| 81 |
+
lt_mask: Tensor | None
|
| 82 |
+
gt_mask: Tensor | None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def collate_batch(batch: Iterable[Datum]) -> TrainingBatch:
|
| 86 |
+
mgs, V_ds, x_ds, ys, weights, lt_masks, gt_masks = zip(*batch)
|
| 87 |
+
|
| 88 |
+
return TrainingBatch(
|
| 89 |
+
BatchMolGraph(mgs),
|
| 90 |
+
None if V_ds[0] is None else torch.from_numpy(np.concatenate(V_ds)).float(),
|
| 91 |
+
None if x_ds[0] is None else torch.from_numpy(np.array(x_ds)).float(),
|
| 92 |
+
None if ys[0] is None else torch.from_numpy(np.array(ys)).float(),
|
| 93 |
+
torch.tensor(weights, dtype=torch.float).unsqueeze(1),
|
| 94 |
+
None if lt_masks[0] is None else torch.from_numpy(np.array(lt_masks)),
|
| 95 |
+
None if gt_masks[0] is None else torch.from_numpy(np.array(gt_masks)),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class MulticomponentTrainingBatch(NamedTuple):
|
| 100 |
+
bmgs: list[BatchMolGraph]
|
| 101 |
+
V_ds: list[Tensor | None]
|
| 102 |
+
X_d: Tensor | None
|
| 103 |
+
Y: Tensor | None
|
| 104 |
+
w: Tensor
|
| 105 |
+
lt_mask: Tensor | None
|
| 106 |
+
gt_mask: Tensor | None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def collate_multicomponent(batches: Iterable[Iterable[Datum]]) -> MulticomponentTrainingBatch:
|
| 110 |
+
tbs = [collate_batch(batch) for batch in zip(*batches)]
|
| 111 |
+
|
| 112 |
+
return MulticomponentTrainingBatch(
|
| 113 |
+
[tb.bmg for tb in tbs],
|
| 114 |
+
[tb.V_d for tb in tbs],
|
| 115 |
+
tbs[0].X_d,
|
| 116 |
+
tbs[0].Y,
|
| 117 |
+
tbs[0].w,
|
| 118 |
+
tbs[0].lt_mask,
|
| 119 |
+
tbs[0].gt_mask,
|
| 120 |
+
)
|
chemprop/data/dataloader.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import warnings
|
| 2 |
+
|
| 3 |
+
from torch.utils.data import DataLoader
|
| 4 |
+
|
| 5 |
+
from chemprop.data.collate import collate_batch, collate_multicomponent
|
| 6 |
+
from chemprop.data.datasets import MoleculeDataset, MulticomponentDataset, ReactionDataset
|
| 7 |
+
from chemprop.data.samplers import ClassBalanceSampler, SeededSampler
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def build_dataloader(
|
| 11 |
+
dataset: MoleculeDataset | ReactionDataset | MulticomponentDataset,
|
| 12 |
+
batch_size: int = 64,
|
| 13 |
+
num_workers: int = 0,
|
| 14 |
+
class_balance: bool = False,
|
| 15 |
+
seed: int | None = None,
|
| 16 |
+
shuffle: bool = True,
|
| 17 |
+
**kwargs,
|
| 18 |
+
):
|
| 19 |
+
"""Return a :obj:`~torch.utils.data.DataLoader` for :class:`MolGraphDataset`\s
|
| 20 |
+
|
| 21 |
+
Parameters
|
| 22 |
+
----------
|
| 23 |
+
dataset : MoleculeDataset | ReactionDataset | MulticomponentDataset
|
| 24 |
+
The dataset containing the molecules or reactions to load.
|
| 25 |
+
batch_size : int, default=64
|
| 26 |
+
the batch size to load.
|
| 27 |
+
num_workers : int, default=0
|
| 28 |
+
the number of workers used to build batches.
|
| 29 |
+
class_balance : bool, default=False
|
| 30 |
+
Whether to perform class balancing (i.e., use an equal number of positive and negative
|
| 31 |
+
molecules). Class balance is only available for single task classification datasets. Set
|
| 32 |
+
shuffle to True in order to get a random subset of the larger class.
|
| 33 |
+
seed : int, default=None
|
| 34 |
+
the random seed to use for shuffling (only used when `shuffle` is `True`).
|
| 35 |
+
shuffle : bool, default=False
|
| 36 |
+
whether to shuffle the data during sampling.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
if class_balance:
|
| 40 |
+
sampler = ClassBalanceSampler(dataset.Y, seed, shuffle)
|
| 41 |
+
elif shuffle and seed is not None:
|
| 42 |
+
sampler = SeededSampler(len(dataset), seed)
|
| 43 |
+
else:
|
| 44 |
+
sampler = None
|
| 45 |
+
|
| 46 |
+
if isinstance(dataset, MulticomponentDataset):
|
| 47 |
+
collate_fn = collate_multicomponent
|
| 48 |
+
else:
|
| 49 |
+
collate_fn = collate_batch
|
| 50 |
+
|
| 51 |
+
if len(dataset) % batch_size == 1:
|
| 52 |
+
warnings.warn(
|
| 53 |
+
f"Dropping last batch of size 1 to avoid issues with batch normalization \
|
| 54 |
+
(dataset size = {len(dataset)}, batch_size = {batch_size})"
|
| 55 |
+
)
|
| 56 |
+
drop_last = True
|
| 57 |
+
else:
|
| 58 |
+
drop_last = False
|
| 59 |
+
|
| 60 |
+
return DataLoader(
|
| 61 |
+
dataset,
|
| 62 |
+
batch_size,
|
| 63 |
+
sampler is None and shuffle,
|
| 64 |
+
sampler,
|
| 65 |
+
num_workers=num_workers,
|
| 66 |
+
collate_fn=collate_fn,
|
| 67 |
+
drop_last=drop_last,
|
| 68 |
+
**kwargs,
|
| 69 |
+
)
|
chemprop/data/datapoints.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import InitVar, dataclass
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from rdkit.Chem import AllChem as Chem
|
| 7 |
+
|
| 8 |
+
from chemprop.featurizers import Featurizer
|
| 9 |
+
from chemprop.utils import make_mol
|
| 10 |
+
|
| 11 |
+
MoleculeFeaturizer = Featurizer[Chem.Mol, np.ndarray]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(slots=True)
|
| 15 |
+
class _DatapointMixin:
|
| 16 |
+
"""A mixin class for both molecule- and reaction- and multicomponent-type data"""
|
| 17 |
+
|
| 18 |
+
y: np.ndarray | None = None
|
| 19 |
+
"""the targets for the molecule with unknown targets indicated by `nan`s"""
|
| 20 |
+
weight: float = 1.0
|
| 21 |
+
"""the weight of this datapoint for the loss calculation."""
|
| 22 |
+
gt_mask: np.ndarray | None = None
|
| 23 |
+
"""Indicates whether the targets are an inequality regression target of the form `<x`"""
|
| 24 |
+
lt_mask: np.ndarray | None = None
|
| 25 |
+
"""Indicates whether the targets are an inequality regression target of the form `>x`"""
|
| 26 |
+
x_d: np.ndarray | None = None
|
| 27 |
+
"""A vector of length ``d_f`` containing additional features (e.g., Morgan fingerprint) that
|
| 28 |
+
will be concatenated to the global representation *after* aggregation"""
|
| 29 |
+
mfs: InitVar[list[MoleculeFeaturizer] | None] = None
|
| 30 |
+
"""A list of molecule featurizers to use"""
|
| 31 |
+
x_phase: list[float] = None
|
| 32 |
+
"""A one-hot vector indicating the phase of the data, as used in spectra data."""
|
| 33 |
+
name: str | None = None
|
| 34 |
+
"""A string identifier for the datapoint."""
|
| 35 |
+
|
| 36 |
+
def __post_init__(self, mfs: list[MoleculeFeaturizer] | None):
|
| 37 |
+
if self.x_d is not None and mfs is not None:
|
| 38 |
+
raise ValueError("Cannot provide both loaded features and molecular featurizers!")
|
| 39 |
+
|
| 40 |
+
if mfs is not None:
|
| 41 |
+
self.x_d = self.calc_features(mfs)
|
| 42 |
+
|
| 43 |
+
NAN_TOKEN = 0
|
| 44 |
+
if self.x_d is not None:
|
| 45 |
+
self.x_d[np.isnan(self.x_d)] = NAN_TOKEN
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def t(self) -> int | None:
|
| 49 |
+
return len(self.y) if self.y is not None else None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class _MoleculeDatapointMixin:
|
| 54 |
+
mol: Chem.Mol
|
| 55 |
+
"""the molecule associated with this datapoint"""
|
| 56 |
+
|
| 57 |
+
@classmethod
|
| 58 |
+
def from_smi(
|
| 59 |
+
cls, smi: str, *args, keep_h: bool = False, add_h: bool = False, **kwargs
|
| 60 |
+
) -> _MoleculeDatapointMixin:
|
| 61 |
+
mol = make_mol(smi, keep_h, add_h)
|
| 62 |
+
|
| 63 |
+
kwargs["name"] = smi if "name" not in kwargs else kwargs["name"]
|
| 64 |
+
|
| 65 |
+
return cls(mol, *args, **kwargs)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class MoleculeDatapoint(_DatapointMixin, _MoleculeDatapointMixin):
|
| 70 |
+
"""A :class:`MoleculeDatapoint` contains a single molecule and its associated features and targets."""
|
| 71 |
+
|
| 72 |
+
V_f: np.ndarray | None = None
|
| 73 |
+
"""a numpy array of shape ``V x d_vf``, where ``V`` is the number of atoms in the molecule, and
|
| 74 |
+
``d_vf`` is the number of additional features that will be concatenated to atom-level features
|
| 75 |
+
*before* message passing"""
|
| 76 |
+
E_f: np.ndarray | None = None
|
| 77 |
+
"""A numpy array of shape ``E x d_ef``, where ``E`` is the number of bonds in the molecule, and
|
| 78 |
+
``d_ef`` is the number of additional features containing additional features that will be
|
| 79 |
+
concatenated to bond-level features *before* message passing"""
|
| 80 |
+
V_d: np.ndarray | None = None
|
| 81 |
+
"""A numpy array of shape ``V x d_vd``, where ``V`` is the number of atoms in the molecule, and
|
| 82 |
+
``d_vd`` is the number of additional descriptors that will be concatenated to atom-level
|
| 83 |
+
descriptors *after* message passing"""
|
| 84 |
+
|
| 85 |
+
def __post_init__(self, mfs: list[MoleculeFeaturizer] | None):
|
| 86 |
+
if self.mol is None:
|
| 87 |
+
raise ValueError("Input molecule was `None`!")
|
| 88 |
+
|
| 89 |
+
NAN_TOKEN = 0
|
| 90 |
+
|
| 91 |
+
if self.V_f is not None:
|
| 92 |
+
self.V_f[np.isnan(self.V_f)] = NAN_TOKEN
|
| 93 |
+
if self.E_f is not None:
|
| 94 |
+
self.E_f[np.isnan(self.E_f)] = NAN_TOKEN
|
| 95 |
+
if self.V_d is not None:
|
| 96 |
+
self.V_d[np.isnan(self.V_d)] = NAN_TOKEN
|
| 97 |
+
|
| 98 |
+
super().__post_init__(mfs)
|
| 99 |
+
|
| 100 |
+
def __len__(self) -> int:
|
| 101 |
+
return 1
|
| 102 |
+
|
| 103 |
+
def calc_features(self, mfs: list[MoleculeFeaturizer]) -> np.ndarray:
|
| 104 |
+
if self.mol.GetNumHeavyAtoms() == 0:
|
| 105 |
+
return np.zeros(sum(len(mf) for mf in mfs))
|
| 106 |
+
|
| 107 |
+
return np.hstack([mf(self.mol) for mf in mfs])
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@dataclass
|
| 111 |
+
class _ReactionDatapointMixin:
|
| 112 |
+
rct: Chem.Mol
|
| 113 |
+
"""the reactant associated with this datapoint"""
|
| 114 |
+
pdt: Chem.Mol
|
| 115 |
+
"""the product associated with this datapoint"""
|
| 116 |
+
|
| 117 |
+
@classmethod
|
| 118 |
+
def from_smi(
|
| 119 |
+
cls,
|
| 120 |
+
rxn_or_smis: str | tuple[str, str],
|
| 121 |
+
*args,
|
| 122 |
+
keep_h: bool = False,
|
| 123 |
+
add_h: bool = False,
|
| 124 |
+
**kwargs,
|
| 125 |
+
) -> _ReactionDatapointMixin:
|
| 126 |
+
match rxn_or_smis:
|
| 127 |
+
case str():
|
| 128 |
+
rct_smi, agt_smi, pdt_smi = rxn_or_smis.split(">")
|
| 129 |
+
rct_smi = f"{rct_smi}.{agt_smi}" if agt_smi else rct_smi
|
| 130 |
+
name = rxn_or_smis
|
| 131 |
+
case tuple():
|
| 132 |
+
rct_smi, pdt_smi = rxn_or_smis
|
| 133 |
+
name = ">>".join(rxn_or_smis)
|
| 134 |
+
case _:
|
| 135 |
+
raise TypeError(
|
| 136 |
+
"Must provide either a reaction SMARTS string or a tuple of reactant and product SMILES strings!"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
rct = make_mol(rct_smi, keep_h, add_h)
|
| 140 |
+
pdt = make_mol(pdt_smi, keep_h, add_h)
|
| 141 |
+
|
| 142 |
+
kwargs["name"] = name if "name" not in kwargs else kwargs["name"]
|
| 143 |
+
|
| 144 |
+
return cls(rct, pdt, *args, **kwargs)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@dataclass
|
| 148 |
+
class ReactionDatapoint(_DatapointMixin, _ReactionDatapointMixin):
|
| 149 |
+
"""A :class:`ReactionDatapoint` contains a single reaction and its associated features and targets."""
|
| 150 |
+
|
| 151 |
+
def __post_init__(self, mfs: list[MoleculeFeaturizer] | None):
|
| 152 |
+
if self.rct is None:
|
| 153 |
+
raise ValueError("Reactant cannot be `None`!")
|
| 154 |
+
if self.pdt is None:
|
| 155 |
+
raise ValueError("Product cannot be `None`!")
|
| 156 |
+
|
| 157 |
+
return super().__post_init__(mfs)
|
| 158 |
+
|
| 159 |
+
def __len__(self) -> int:
|
| 160 |
+
return 2
|
| 161 |
+
|
| 162 |
+
def calc_features(self, mfs: list[MoleculeFeaturizer]) -> np.ndarray:
|
| 163 |
+
x_ds = [
|
| 164 |
+
mf(mol) if mol.GetNumHeavyAtoms() > 0 else np.zeros(len(mf))
|
| 165 |
+
for mf in mfs
|
| 166 |
+
for mol in [self.rct, self.pdt]
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
return np.hstack(x_ds)
|
chemprop/data/datasets.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from functools import cached_property
|
| 3 |
+
from typing import NamedTuple, TypeAlias
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from numpy.typing import ArrayLike
|
| 7 |
+
from rdkit import Chem
|
| 8 |
+
from rdkit.Chem import Mol
|
| 9 |
+
from sklearn.preprocessing import StandardScaler
|
| 10 |
+
from torch.utils.data import Dataset
|
| 11 |
+
|
| 12 |
+
from chemprop.types import Rxn
|
| 13 |
+
from chemprop.data.datapoints import MoleculeDatapoint, ReactionDatapoint
|
| 14 |
+
from chemprop.data.molgraph import MolGraph
|
| 15 |
+
from chemprop.featurizers.base import Featurizer
|
| 16 |
+
from chemprop.featurizers.molgraph.cache import MolGraphCache, MolGraphCacheOnTheFly
|
| 17 |
+
from chemprop.featurizers.molgraph import SimpleMoleculeMolGraphFeaturizer, CGRFeaturizer
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Datum(NamedTuple):
|
| 21 |
+
"""a singular training data point"""
|
| 22 |
+
|
| 23 |
+
mg: MolGraph
|
| 24 |
+
V_d: np.ndarray | None
|
| 25 |
+
x_d: np.ndarray | None
|
| 26 |
+
y: np.ndarray | None
|
| 27 |
+
weight: float
|
| 28 |
+
lt_mask: np.ndarray | None
|
| 29 |
+
gt_mask: np.ndarray | None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
MolGraphDataset: TypeAlias = Dataset[Datum]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class _MolGraphDatasetMixin:
|
| 36 |
+
def __len__(self) -> int:
|
| 37 |
+
return len(self.data)
|
| 38 |
+
|
| 39 |
+
@cached_property
|
| 40 |
+
def _Y(self) -> np.ndarray:
|
| 41 |
+
"""the raw targets of the dataset"""
|
| 42 |
+
return np.array([d.y for d in self.data], float)
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def Y(self) -> np.ndarray:
|
| 46 |
+
"""the (scaled) targets of the dataset"""
|
| 47 |
+
return self.__Y
|
| 48 |
+
|
| 49 |
+
@Y.setter
|
| 50 |
+
def Y(self, Y: ArrayLike):
|
| 51 |
+
self._validate_attribute(Y, "targets")
|
| 52 |
+
|
| 53 |
+
self.__Y = np.array(Y, float)
|
| 54 |
+
|
| 55 |
+
@cached_property
|
| 56 |
+
def _X_d(self) -> np.ndarray:
|
| 57 |
+
"""the raw extra descriptors of the dataset"""
|
| 58 |
+
return np.array([d.x_d for d in self.data])
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def X_d(self) -> np.ndarray:
|
| 62 |
+
"""the (scaled) extra descriptors of the dataset"""
|
| 63 |
+
return self.__X_d
|
| 64 |
+
|
| 65 |
+
@X_d.setter
|
| 66 |
+
def X_d(self, X_d: ArrayLike):
|
| 67 |
+
self._validate_attribute(X_d, "extra descriptors")
|
| 68 |
+
|
| 69 |
+
self.__X_d = np.array(X_d)
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def weights(self) -> np.ndarray:
|
| 73 |
+
return np.array([d.weight for d in self.data])
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def gt_mask(self) -> np.ndarray:
|
| 77 |
+
return np.array([d.gt_mask for d in self.data])
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def lt_mask(self) -> np.ndarray:
|
| 81 |
+
return np.array([d.lt_mask for d in self.data])
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def t(self) -> int | None:
|
| 85 |
+
return self.data[0].t if len(self.data) > 0 else None
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def d_xd(self) -> int:
|
| 89 |
+
"""the extra molecule descriptor dimension, if any"""
|
| 90 |
+
return 0 if self.X_d[0] is None else self.X_d.shape[1]
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def names(self) -> list[str]:
|
| 94 |
+
return [d.name for d in self.data]
|
| 95 |
+
|
| 96 |
+
def normalize_targets(self, scaler: StandardScaler | None = None) -> StandardScaler:
|
| 97 |
+
"""Normalizes the targets of this dataset using a :obj:`StandardScaler`
|
| 98 |
+
|
| 99 |
+
The :obj:`StandardScaler` subtracts the mean and divides by the standard deviation for
|
| 100 |
+
each task independently. NOTE: This should only be used for regression datasets.
|
| 101 |
+
|
| 102 |
+
Returns
|
| 103 |
+
-------
|
| 104 |
+
StandardScaler
|
| 105 |
+
a scaler fit to the targets.
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
if scaler is None:
|
| 109 |
+
scaler = StandardScaler().fit(self._Y)
|
| 110 |
+
|
| 111 |
+
self.Y = scaler.transform(self._Y)
|
| 112 |
+
|
| 113 |
+
return scaler
|
| 114 |
+
|
| 115 |
+
def normalize_inputs(
|
| 116 |
+
self, key: str = "X_d", scaler: StandardScaler | None = None
|
| 117 |
+
) -> StandardScaler:
|
| 118 |
+
VALID_KEYS = {"X_d"}
|
| 119 |
+
if key not in VALID_KEYS:
|
| 120 |
+
raise ValueError(f"Invalid feature key! got: {key}. expected one of: {VALID_KEYS}")
|
| 121 |
+
|
| 122 |
+
X = self.X_d if self.X_d[0] is not None else None
|
| 123 |
+
|
| 124 |
+
if X is None:
|
| 125 |
+
return scaler
|
| 126 |
+
|
| 127 |
+
if scaler is None:
|
| 128 |
+
scaler = StandardScaler().fit(X)
|
| 129 |
+
|
| 130 |
+
self.X_d = scaler.transform(X)
|
| 131 |
+
|
| 132 |
+
return scaler
|
| 133 |
+
|
| 134 |
+
def reset(self):
|
| 135 |
+
"""Reset the atom and bond features; atom and extra descriptors; and targets of each
|
| 136 |
+
datapoint to their initial, unnormalized values."""
|
| 137 |
+
self.__Y = self._Y
|
| 138 |
+
self.__X_d = self._X_d
|
| 139 |
+
|
| 140 |
+
def _validate_attribute(self, X: np.ndarray, label: str):
|
| 141 |
+
if not len(self.data) == len(X):
|
| 142 |
+
raise ValueError(
|
| 143 |
+
f"number of molecules ({len(self.data)}) and {label} ({len(X)}) "
|
| 144 |
+
"must have same length!"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@dataclass
|
| 149 |
+
class MoleculeDataset(_MolGraphDatasetMixin, MolGraphDataset):
|
| 150 |
+
"""A :class:`MoleculeDataset` composed of :class:`MoleculeDatapoint`\s
|
| 151 |
+
|
| 152 |
+
A :class:`MoleculeDataset` produces featurized data for input to a
|
| 153 |
+
:class:`MPNN` model. Typically, data featurization is performed on-the-fly
|
| 154 |
+
and parallelized across multiple workers via the :class:`~torch.utils.data
|
| 155 |
+
DataLoader` class. However, for small datasets, it may be more efficient to
|
| 156 |
+
featurize the data in advance and cache the results. This can be done by
|
| 157 |
+
setting ``MoleculeDataset.cache=True``.
|
| 158 |
+
|
| 159 |
+
Parameters
|
| 160 |
+
----------
|
| 161 |
+
data : Iterable[MoleculeDatapoint]
|
| 162 |
+
the data from which to create a dataset
|
| 163 |
+
featurizer : MoleculeFeaturizer
|
| 164 |
+
the featurizer with which to generate MolGraphs of the molecules
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
data: list[MoleculeDatapoint]
|
| 168 |
+
featurizer: Featurizer[Mol, MolGraph] = field(default_factory=SimpleMoleculeMolGraphFeaturizer)
|
| 169 |
+
|
| 170 |
+
def __post_init__(self):
|
| 171 |
+
if self.data is None:
|
| 172 |
+
raise ValueError("Data cannot be None!")
|
| 173 |
+
|
| 174 |
+
self.reset()
|
| 175 |
+
self.cache = False
|
| 176 |
+
|
| 177 |
+
def __getitem__(self, idx: int) -> Datum:
|
| 178 |
+
d = self.data[idx]
|
| 179 |
+
mg = self.mg_cache[idx]
|
| 180 |
+
|
| 181 |
+
return Datum(mg, self.V_ds[idx], self.X_d[idx], self.Y[idx], d.weight, d.lt_mask, d.gt_mask)
|
| 182 |
+
|
| 183 |
+
@property
|
| 184 |
+
def cache(self) -> bool:
|
| 185 |
+
return self.__cache
|
| 186 |
+
|
| 187 |
+
@cache.setter
|
| 188 |
+
def cache(self, cache: bool = False):
|
| 189 |
+
self.__cache = cache
|
| 190 |
+
self._init_cache()
|
| 191 |
+
|
| 192 |
+
def _init_cache(self):
|
| 193 |
+
"""initialize the cache"""
|
| 194 |
+
self.mg_cache = (MolGraphCache if self.cache else MolGraphCacheOnTheFly)(
|
| 195 |
+
self.mols, self.V_fs, self.E_fs, self.featurizer
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
@property
|
| 199 |
+
def smiles(self) -> list[str]:
|
| 200 |
+
"""the SMILES strings associated with the dataset"""
|
| 201 |
+
return [Chem.MolToSmiles(d.mol) for d in self.data]
|
| 202 |
+
|
| 203 |
+
@property
|
| 204 |
+
def mols(self) -> list[Chem.Mol]:
|
| 205 |
+
"""the molecules associated with the dataset"""
|
| 206 |
+
return [d.mol for d in self.data]
|
| 207 |
+
|
| 208 |
+
@property
|
| 209 |
+
def _V_fs(self) -> list[np.ndarray]:
|
| 210 |
+
"""the raw atom features of the dataset"""
|
| 211 |
+
return [d.V_f for d in self.data]
|
| 212 |
+
|
| 213 |
+
@property
|
| 214 |
+
def V_fs(self) -> list[np.ndarray]:
|
| 215 |
+
"""the (scaled) atom descriptors of the dataset"""
|
| 216 |
+
return self.__V_fs
|
| 217 |
+
|
| 218 |
+
@V_fs.setter
|
| 219 |
+
def V_fs(self, V_fs: list[np.ndarray]):
|
| 220 |
+
"""the (scaled) atom features of the dataset"""
|
| 221 |
+
self._validate_attribute(V_fs, "atom features")
|
| 222 |
+
|
| 223 |
+
self.__V_fs = V_fs
|
| 224 |
+
self._init_cache()
|
| 225 |
+
|
| 226 |
+
@property
|
| 227 |
+
def _E_fs(self) -> list[np.ndarray]:
|
| 228 |
+
"""the raw bond features of the dataset"""
|
| 229 |
+
return [d.E_f for d in self.data]
|
| 230 |
+
|
| 231 |
+
@property
|
| 232 |
+
def E_fs(self) -> list[np.ndarray]:
|
| 233 |
+
"""the (scaled) bond features of the dataset"""
|
| 234 |
+
return self.__E_fs
|
| 235 |
+
|
| 236 |
+
@E_fs.setter
|
| 237 |
+
def E_fs(self, E_fs: list[np.ndarray]):
|
| 238 |
+
self._validate_attribute(E_fs, "bond features")
|
| 239 |
+
|
| 240 |
+
self.__E_fs = E_fs
|
| 241 |
+
self._init_cache()
|
| 242 |
+
|
| 243 |
+
@property
|
| 244 |
+
def _V_ds(self) -> list[np.ndarray]:
|
| 245 |
+
"""the raw atom descriptors of the dataset"""
|
| 246 |
+
return [d.V_d for d in self.data]
|
| 247 |
+
|
| 248 |
+
@property
|
| 249 |
+
def V_ds(self) -> list[np.ndarray]:
|
| 250 |
+
"""the (scaled) atom descriptors of the dataset"""
|
| 251 |
+
return self.__V_ds
|
| 252 |
+
|
| 253 |
+
@V_ds.setter
|
| 254 |
+
def V_ds(self, V_ds: list[np.ndarray]):
|
| 255 |
+
self._validate_attribute(V_ds, "atom descriptors")
|
| 256 |
+
|
| 257 |
+
self.__V_ds = V_ds
|
| 258 |
+
|
| 259 |
+
@property
|
| 260 |
+
def d_vf(self) -> int:
|
| 261 |
+
"""the extra atom feature dimension, if any"""
|
| 262 |
+
return 0 if self.V_fs[0] is None else self.V_fs[0].shape[1]
|
| 263 |
+
|
| 264 |
+
@property
|
| 265 |
+
def d_ef(self) -> int:
|
| 266 |
+
"""the extra bond feature dimension, if any"""
|
| 267 |
+
return 0 if self.E_fs[0] is None else self.E_fs[0].shape[1]
|
| 268 |
+
|
| 269 |
+
@property
|
| 270 |
+
def d_vd(self) -> int:
|
| 271 |
+
"""the extra atom descriptor dimension, if any"""
|
| 272 |
+
return 0 if self.V_ds[0] is None else self.V_ds[0].shape[1]
|
| 273 |
+
|
| 274 |
+
def normalize_inputs(
|
| 275 |
+
self, key: str = "X_d", scaler: StandardScaler | None = None
|
| 276 |
+
) -> StandardScaler:
|
| 277 |
+
VALID_KEYS = {"X_d", "V_f", "E_f", "V_d"}
|
| 278 |
+
|
| 279 |
+
match key:
|
| 280 |
+
case "X_d":
|
| 281 |
+
X = None if self.d_xd == 0 else self.X_d
|
| 282 |
+
case "V_f":
|
| 283 |
+
X = None if self.d_vf == 0 else np.concatenate(self.V_fs, axis=0)
|
| 284 |
+
case "E_f":
|
| 285 |
+
X = None if self.d_ef == 0 else np.concatenate(self.E_fs, axis=0)
|
| 286 |
+
case "V_d":
|
| 287 |
+
X = None if self.d_vd == 0 else np.concatenate(self.V_ds, axis=0)
|
| 288 |
+
case _:
|
| 289 |
+
raise ValueError(f"Invalid feature key! got: {key}. expected one of: {VALID_KEYS}")
|
| 290 |
+
|
| 291 |
+
if X is None:
|
| 292 |
+
return scaler
|
| 293 |
+
|
| 294 |
+
if scaler is None:
|
| 295 |
+
scaler = StandardScaler().fit(X)
|
| 296 |
+
|
| 297 |
+
match key:
|
| 298 |
+
case "X_d":
|
| 299 |
+
self.X_d = scaler.transform(X)
|
| 300 |
+
case "V_f":
|
| 301 |
+
self.V_fs = [scaler.transform(V_f) if V_f.size > 0 else V_f for V_f in self.V_fs]
|
| 302 |
+
case "E_f":
|
| 303 |
+
self.E_fs = [scaler.transform(E_f) if E_f.size > 0 else E_f for E_f in self.E_fs]
|
| 304 |
+
case "V_d":
|
| 305 |
+
self.V_ds = [scaler.transform(V_d) if V_d.size > 0 else V_d for V_d in self.V_ds]
|
| 306 |
+
case _:
|
| 307 |
+
raise RuntimeError("unreachable code reached!")
|
| 308 |
+
|
| 309 |
+
return scaler
|
| 310 |
+
|
| 311 |
+
def reset(self):
|
| 312 |
+
"""Reset the atom and bond features; atom and extra descriptors; and targets of each
|
| 313 |
+
datapoint to their initial, unnormalized values."""
|
| 314 |
+
super().reset()
|
| 315 |
+
self.__V_fs = self._V_fs
|
| 316 |
+
self.__E_fs = self._E_fs
|
| 317 |
+
self.__V_ds = self._V_ds
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
@dataclass
|
| 321 |
+
class ReactionDataset(_MolGraphDatasetMixin, MolGraphDataset):
|
| 322 |
+
"""A :class:`ReactionDataset` composed of :class:`ReactionDatapoint`\s
|
| 323 |
+
|
| 324 |
+
.. note::
|
| 325 |
+
The featurized data provided by this class may be cached, simlar to a
|
| 326 |
+
:class:`MoleculeDataset`. To enable the cache, set ``ReactionDataset
|
| 327 |
+
cache=True``.
|
| 328 |
+
"""
|
| 329 |
+
|
| 330 |
+
data: list[ReactionDatapoint]
|
| 331 |
+
"""the dataset from which to load"""
|
| 332 |
+
featurizer: Featurizer[Rxn, MolGraph] = field(default_factory=CGRFeaturizer)
|
| 333 |
+
"""the featurizer with which to generate MolGraphs of the input"""
|
| 334 |
+
|
| 335 |
+
def __post_init__(self):
|
| 336 |
+
if self.data is None:
|
| 337 |
+
raise ValueError("Data cannot be None!")
|
| 338 |
+
|
| 339 |
+
self.reset()
|
| 340 |
+
self.cache = False
|
| 341 |
+
|
| 342 |
+
@property
|
| 343 |
+
def cache(self) -> bool:
|
| 344 |
+
return self.__cache
|
| 345 |
+
|
| 346 |
+
@cache.setter
|
| 347 |
+
def cache(self, cache: bool = False):
|
| 348 |
+
self.__cache = cache
|
| 349 |
+
self.mg_cache = (MolGraphCache if cache else MolGraphCacheOnTheFly)(
|
| 350 |
+
self.mols, [None] * len(self), [None] * len(self), self.featurizer
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
def __getitem__(self, idx: int) -> Datum:
|
| 354 |
+
d = self.data[idx]
|
| 355 |
+
mg = self.mg_cache[idx]
|
| 356 |
+
|
| 357 |
+
return Datum(mg, None, self.X_d[idx], self.Y[idx], d.weight, d.lt_mask, d.gt_mask)
|
| 358 |
+
|
| 359 |
+
@property
|
| 360 |
+
def smiles(self) -> list[tuple]:
|
| 361 |
+
return [(Chem.MolToSmiles(d.rct), Chem.MolToSmiles(d.pdt)) for d in self.data]
|
| 362 |
+
|
| 363 |
+
@property
|
| 364 |
+
def mols(self) -> list[Rxn]:
|
| 365 |
+
return [(d.rct, d.pdt) for d in self.data]
|
| 366 |
+
|
| 367 |
+
@property
|
| 368 |
+
def d_vf(self) -> int:
|
| 369 |
+
return 0
|
| 370 |
+
|
| 371 |
+
@property
|
| 372 |
+
def d_ef(self) -> int:
|
| 373 |
+
return 0
|
| 374 |
+
|
| 375 |
+
@property
|
| 376 |
+
def d_vd(self) -> int:
|
| 377 |
+
return 0
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
@dataclass(repr=False, eq=False)
|
| 381 |
+
class MulticomponentDataset(_MolGraphDatasetMixin, Dataset):
|
| 382 |
+
"""A :class:`MulticomponentDataset` is a :class:`Dataset` composed of parallel
|
| 383 |
+
:class:`MoleculeDatasets` and :class:`ReactionDataset`\s"""
|
| 384 |
+
|
| 385 |
+
datasets: list[MoleculeDataset | ReactionDataset]
|
| 386 |
+
"""the parallel datasets"""
|
| 387 |
+
|
| 388 |
+
def __post_init__(self):
|
| 389 |
+
sizes = [len(dset) for dset in self.datasets]
|
| 390 |
+
if not all(sizes[0] == size for size in sizes[1:]):
|
| 391 |
+
raise ValueError(f"Datasets must have all same length! got: {sizes}")
|
| 392 |
+
|
| 393 |
+
def __len__(self) -> int:
|
| 394 |
+
return len(self.datasets[0])
|
| 395 |
+
|
| 396 |
+
@property
|
| 397 |
+
def n_components(self) -> int:
|
| 398 |
+
return len(self.datasets)
|
| 399 |
+
|
| 400 |
+
def __getitem__(self, idx: int) -> list[Datum]:
|
| 401 |
+
return [dset[idx] for dset in self.datasets]
|
| 402 |
+
|
| 403 |
+
@property
|
| 404 |
+
def smiles(self) -> list[list[str]]:
|
| 405 |
+
return list(zip(*[dset.smiles for dset in self.datasets]))
|
| 406 |
+
|
| 407 |
+
@property
|
| 408 |
+
def names(self) -> list[list[str]]:
|
| 409 |
+
return list(zip(*[dset.names for dset in self.datasets]))
|
| 410 |
+
|
| 411 |
+
@property
|
| 412 |
+
def mols(self) -> list[list[Chem.Mol]]:
|
| 413 |
+
return list(zip(*[dset.mols for dset in self.datasets]))
|
| 414 |
+
|
| 415 |
+
def normalize_targets(self, scaler: StandardScaler | None = None) -> StandardScaler:
|
| 416 |
+
return self.datasets[0].normalize_targets(scaler)
|
| 417 |
+
|
| 418 |
+
def normalize_inputs(
|
| 419 |
+
self, key: str = "X_d", scaler: list[StandardScaler] | None = None
|
| 420 |
+
) -> list[StandardScaler]:
|
| 421 |
+
RXN_VALID_KEYS = {"X_d"}
|
| 422 |
+
match scaler:
|
| 423 |
+
case None:
|
| 424 |
+
return [
|
| 425 |
+
dset.normalize_inputs(key)
|
| 426 |
+
if isinstance(dset, MoleculeDataset) or key in RXN_VALID_KEYS
|
| 427 |
+
else None
|
| 428 |
+
for dset in self.datasets
|
| 429 |
+
]
|
| 430 |
+
case _:
|
| 431 |
+
assert len(scaler) == len(
|
| 432 |
+
self.datasets
|
| 433 |
+
), "Number of scalers must match number of datasets!"
|
| 434 |
+
|
| 435 |
+
return [
|
| 436 |
+
dset.normalize_inputs(key, s)
|
| 437 |
+
if isinstance(dset, MoleculeDataset) or key in RXN_VALID_KEYS
|
| 438 |
+
else None
|
| 439 |
+
for dset, s in zip(self.datasets, scaler)
|
| 440 |
+
]
|
| 441 |
+
|
| 442 |
+
def reset(self):
|
| 443 |
+
return [dset.reset() for dset in self.datasets]
|
| 444 |
+
|
| 445 |
+
@property
|
| 446 |
+
def d_xd(self) -> list[int]:
|
| 447 |
+
return self.datasets[0].d_xd
|
| 448 |
+
|
| 449 |
+
@property
|
| 450 |
+
def d_vf(self) -> list[int]:
|
| 451 |
+
return sum(dset.d_vf for dset in self.datasets)
|
| 452 |
+
|
| 453 |
+
@property
|
| 454 |
+
def d_ef(self) -> list[int]:
|
| 455 |
+
return sum(dset.d_ef for dset in self.datasets)
|
| 456 |
+
|
| 457 |
+
@property
|
| 458 |
+
def d_vd(self) -> list[int]:
|
| 459 |
+
return sum(dset.d_vd for dset in self.datasets)
|
chemprop/data/molgraph.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import NamedTuple
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class MolGraph(NamedTuple):
|
| 7 |
+
"""A :class:`MolGraph` represents the graph featurization of a molecule."""
|
| 8 |
+
|
| 9 |
+
V: np.ndarray
|
| 10 |
+
"""an array of shape ``V x d_v`` containing the atom features of the molecule"""
|
| 11 |
+
E: np.ndarray
|
| 12 |
+
"""an array of shape ``E x d_e`` containing the bond features of the molecule"""
|
| 13 |
+
edge_index: np.ndarray
|
| 14 |
+
"""an array of shape ``2 x E`` containing the edges of the graph in COO format"""
|
| 15 |
+
rev_edge_index: np.ndarray
|
| 16 |
+
"""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."""
|
chemprop/data/samplers.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from itertools import chain
|
| 2 |
+
from typing import Iterator, Optional
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
from torch.utils.data import Sampler
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class SeededSampler(Sampler):
|
| 9 |
+
"""A :class`SeededSampler` is a class for iterating through a dataset in a randomly seeded
|
| 10 |
+
fashion"""
|
| 11 |
+
|
| 12 |
+
def __init__(self, N: int, seed: int):
|
| 13 |
+
if seed is None:
|
| 14 |
+
raise ValueError("arg 'seed' was `None`! A SeededSampler must be seeded!")
|
| 15 |
+
|
| 16 |
+
self.idxs = np.arange(N)
|
| 17 |
+
self.rg = np.random.default_rng(seed)
|
| 18 |
+
|
| 19 |
+
def __iter__(self) -> Iterator[int]:
|
| 20 |
+
"""an iterator over indices to sample."""
|
| 21 |
+
self.rg.shuffle(self.idxs)
|
| 22 |
+
|
| 23 |
+
return iter(self.idxs)
|
| 24 |
+
|
| 25 |
+
def __len__(self) -> int:
|
| 26 |
+
"""the number of indices that will be sampled."""
|
| 27 |
+
return len(self.idxs)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ClassBalanceSampler(Sampler):
|
| 31 |
+
"""A :class:`ClassBalanceSampler` samples data from a :class:`MolGraphDataset` such that
|
| 32 |
+
positive and negative classes are equally sampled
|
| 33 |
+
|
| 34 |
+
Parameters
|
| 35 |
+
----------
|
| 36 |
+
dataset : MolGraphDataset
|
| 37 |
+
the dataset from which to sample
|
| 38 |
+
seed : int
|
| 39 |
+
the random seed to use for shuffling (only used when `shuffle` is `True`)
|
| 40 |
+
shuffle : bool, default=False
|
| 41 |
+
whether to shuffle the data during sampling
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self, Y: np.ndarray, seed: Optional[int] = None, shuffle: bool = False):
|
| 45 |
+
self.shuffle = shuffle
|
| 46 |
+
self.rg = np.random.default_rng(seed)
|
| 47 |
+
|
| 48 |
+
idxs = np.arange(len(Y))
|
| 49 |
+
actives = Y.any(1)
|
| 50 |
+
|
| 51 |
+
self.pos_idxs = idxs[actives]
|
| 52 |
+
self.neg_idxs = idxs[~actives]
|
| 53 |
+
|
| 54 |
+
self.length = 2 * min(len(self.pos_idxs), len(self.neg_idxs))
|
| 55 |
+
|
| 56 |
+
def __iter__(self) -> Iterator[int]:
|
| 57 |
+
"""an iterator over indices to sample."""
|
| 58 |
+
if self.shuffle:
|
| 59 |
+
self.rg.shuffle(self.pos_idxs)
|
| 60 |
+
self.rg.shuffle(self.neg_idxs)
|
| 61 |
+
|
| 62 |
+
return chain(*zip(self.pos_idxs, self.neg_idxs))
|
| 63 |
+
|
| 64 |
+
def __len__(self) -> int:
|
| 65 |
+
"""the number of indices that will be sampled."""
|
| 66 |
+
return self.length
|
chemprop/data/splitting.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import logging
|
| 3 |
+
from enum import auto
|
| 4 |
+
from collections.abc import Sequence, Iterable
|
| 5 |
+
import numpy as np
|
| 6 |
+
from astartes import train_test_split, train_val_test_split
|
| 7 |
+
from astartes.molecules import train_test_split_molecules, train_val_test_split_molecules
|
| 8 |
+
from rdkit import Chem
|
| 9 |
+
|
| 10 |
+
from chemprop.data.datapoints import MoleculeDatapoint, ReactionDatapoint
|
| 11 |
+
from chemprop.utils.utils import EnumMapping
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
Datapoints = Sequence[MoleculeDatapoint] | Sequence[ReactionDatapoint]
|
| 16 |
+
MulticomponentDatapoints = Sequence[Datapoints]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SplitType(EnumMapping):
|
| 20 |
+
CV_NO_VAL = auto()
|
| 21 |
+
CV = auto()
|
| 22 |
+
SCAFFOLD_BALANCED = auto()
|
| 23 |
+
RANDOM_WITH_REPEATED_SMILES = auto()
|
| 24 |
+
RANDOM = auto()
|
| 25 |
+
KENNARD_STONE = auto()
|
| 26 |
+
KMEANS = auto()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def make_split_indices(
|
| 30 |
+
mols: Sequence[Chem.Mol],
|
| 31 |
+
split: SplitType | str = "random",
|
| 32 |
+
sizes: tuple[float, float, float] = (0.8, 0.1, 0.1),
|
| 33 |
+
seed: int = 0,
|
| 34 |
+
num_folds: int = 1,
|
| 35 |
+
):
|
| 36 |
+
"""Splits data into training, validation, and test splits.
|
| 37 |
+
|
| 38 |
+
Parameters
|
| 39 |
+
----------
|
| 40 |
+
mols : Sequence[Chem.Mol]
|
| 41 |
+
Sequence of RDKit molecules to use for structure based splitting
|
| 42 |
+
split : SplitType | str, optional
|
| 43 |
+
Split type, one of ~chemprop.data.utils.SplitType, by default "random"
|
| 44 |
+
sizes : tuple[float, float, float], optional
|
| 45 |
+
3-tuple with the proportions of data in the train, validation, and test sets, by default
|
| 46 |
+
(0.8, 0.1, 0.1). Set the middle value to 0 for a two way split.
|
| 47 |
+
seed : int, optional
|
| 48 |
+
The random seed passed to astartes, by default 0
|
| 49 |
+
num_folds : int, optional
|
| 50 |
+
Number of folds to create (only needed for "cv" and "cv-no-test"), by default 1
|
| 51 |
+
|
| 52 |
+
Returns
|
| 53 |
+
-------
|
| 54 |
+
tuple[list[int], list[int], list[int]] | tuple[list[list[int], ...], list[list[int], ...], list[list[int], ...]]
|
| 55 |
+
A tuple of list of indices corresponding to the train, validation, and test splits of the
|
| 56 |
+
data. If the split type is "cv" or "cv-no-test", returns a tuple of lists of lists of
|
| 57 |
+
indices corresponding to the train, validation, and test splits of each fold.
|
| 58 |
+
.. important::
|
| 59 |
+
validation may or may not be present
|
| 60 |
+
|
| 61 |
+
Raises
|
| 62 |
+
------
|
| 63 |
+
ValueError
|
| 64 |
+
Requested split sizes tuple not of length 3
|
| 65 |
+
ValueError
|
| 66 |
+
Innapropriate number of folds requested
|
| 67 |
+
ValueError
|
| 68 |
+
Unsupported split method requested
|
| 69 |
+
"""
|
| 70 |
+
if (num_splits := len(sizes)) != 3:
|
| 71 |
+
raise ValueError(
|
| 72 |
+
f"Specify sizes for train, validation, and test (got {num_splits} values)."
|
| 73 |
+
)
|
| 74 |
+
# typically include a validation set
|
| 75 |
+
include_val = True
|
| 76 |
+
split_fun = train_val_test_split
|
| 77 |
+
mol_split_fun = train_val_test_split_molecules
|
| 78 |
+
# default sampling arguments for astartes sampler
|
| 79 |
+
astartes_kwargs = dict(
|
| 80 |
+
train_size=sizes[0], test_size=sizes[2], return_indices=True, random_state=seed
|
| 81 |
+
)
|
| 82 |
+
# if no validation set, reassign the splitting functions
|
| 83 |
+
if sizes[1] == 0.0:
|
| 84 |
+
include_val = False
|
| 85 |
+
split_fun = train_test_split
|
| 86 |
+
mol_split_fun = train_test_split_molecules
|
| 87 |
+
else:
|
| 88 |
+
astartes_kwargs["val_size"] = sizes[1]
|
| 89 |
+
|
| 90 |
+
n_datapoints = len(mols)
|
| 91 |
+
train, val, test = None, None, None
|
| 92 |
+
match SplitType.get(split):
|
| 93 |
+
case SplitType.CV_NO_VAL | SplitType.CV:
|
| 94 |
+
min_folds = 2 if SplitType.get(split) == SplitType.CV_NO_VAL else 3
|
| 95 |
+
if not (min_folds <= num_folds <= n_datapoints):
|
| 96 |
+
raise ValueError(
|
| 97 |
+
f"invalid number of folds requested! got: {num_folds}, but expected between "
|
| 98 |
+
f"{min_folds} and {n_datapoints} (i.e., number of datapoints), inclusive, "
|
| 99 |
+
f"for split type: {repr(split)}"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# returns nested lists of indices
|
| 103 |
+
train, val, test = [], [], []
|
| 104 |
+
random = np.random.default_rng(seed)
|
| 105 |
+
|
| 106 |
+
indices = np.tile(np.arange(num_folds), 1 + n_datapoints // num_folds)[:n_datapoints]
|
| 107 |
+
random.shuffle(indices)
|
| 108 |
+
|
| 109 |
+
for fold_idx in range(num_folds):
|
| 110 |
+
test_index = fold_idx
|
| 111 |
+
val_index = (fold_idx + 1) % num_folds
|
| 112 |
+
|
| 113 |
+
if split != SplitType.CV_NO_VAL:
|
| 114 |
+
i_val = np.where(indices == val_index)[0]
|
| 115 |
+
i_test = np.where(indices == test_index)[0]
|
| 116 |
+
i_train = np.where((indices != val_index) & (indices != test_index))[0]
|
| 117 |
+
else:
|
| 118 |
+
i_val = []
|
| 119 |
+
i_test = np.where(indices == test_index)[0]
|
| 120 |
+
i_train = np.where(indices != test_index)[0]
|
| 121 |
+
|
| 122 |
+
train.append(i_train)
|
| 123 |
+
val.append(i_val)
|
| 124 |
+
test.append(i_test)
|
| 125 |
+
|
| 126 |
+
case SplitType.SCAFFOLD_BALANCED:
|
| 127 |
+
mols_without_atommaps = []
|
| 128 |
+
for mol in mols:
|
| 129 |
+
copied_mol = copy.deepcopy(mol)
|
| 130 |
+
for atom in copied_mol.GetAtoms():
|
| 131 |
+
atom.SetAtomMapNum(0)
|
| 132 |
+
mols_without_atommaps.append(copied_mol)
|
| 133 |
+
result = mol_split_fun(
|
| 134 |
+
np.array(mols_without_atommaps), sampler="scaffold", **astartes_kwargs
|
| 135 |
+
)
|
| 136 |
+
train, val, test = _unpack_astartes_result(result, include_val)
|
| 137 |
+
|
| 138 |
+
# Use to constrain data with the same smiles go in the same split.
|
| 139 |
+
case SplitType.RANDOM_WITH_REPEATED_SMILES:
|
| 140 |
+
# get two arrays: one of all the smiles strings, one of just the unique
|
| 141 |
+
all_smiles = np.array([Chem.MolToSmiles(mol) for mol in mols])
|
| 142 |
+
unique_smiles = np.unique(all_smiles)
|
| 143 |
+
|
| 144 |
+
# save a mapping of smiles -> all the indices that it appeared at
|
| 145 |
+
smiles_indices = {}
|
| 146 |
+
for smiles in unique_smiles:
|
| 147 |
+
smiles_indices[smiles] = np.where(all_smiles == smiles)[0].tolist()
|
| 148 |
+
|
| 149 |
+
# randomly split the unique smiles
|
| 150 |
+
result = split_fun(np.arange(len(unique_smiles)), sampler="random", **astartes_kwargs)
|
| 151 |
+
train_idxs, val_idxs, test_idxs = _unpack_astartes_result(result, include_val)
|
| 152 |
+
|
| 153 |
+
# convert these to the 'actual' indices from the original list using the dict we made
|
| 154 |
+
train = sum((smiles_indices[unique_smiles[i]] for i in train_idxs), [])
|
| 155 |
+
val = sum((smiles_indices[unique_smiles[j]] for j in val_idxs), [])
|
| 156 |
+
test = sum((smiles_indices[unique_smiles[k]] for k in test_idxs), [])
|
| 157 |
+
|
| 158 |
+
case SplitType.RANDOM:
|
| 159 |
+
result = split_fun(np.arange(n_datapoints), sampler="random", **astartes_kwargs)
|
| 160 |
+
train, val, test = _unpack_astartes_result(result, include_val)
|
| 161 |
+
|
| 162 |
+
case SplitType.KENNARD_STONE:
|
| 163 |
+
result = mol_split_fun(
|
| 164 |
+
np.array(mols),
|
| 165 |
+
sampler="kennard_stone",
|
| 166 |
+
hopts=dict(metric="jaccard"),
|
| 167 |
+
fingerprint="morgan_fingerprint",
|
| 168 |
+
fprints_hopts=dict(n_bits=2048),
|
| 169 |
+
**astartes_kwargs,
|
| 170 |
+
)
|
| 171 |
+
train, val, test = _unpack_astartes_result(result, include_val)
|
| 172 |
+
|
| 173 |
+
case SplitType.KMEANS:
|
| 174 |
+
result = mol_split_fun(
|
| 175 |
+
np.array(mols),
|
| 176 |
+
sampler="kmeans",
|
| 177 |
+
hopts=dict(metric="jaccard"),
|
| 178 |
+
fingerprint="morgan_fingerprint",
|
| 179 |
+
fprints_hopts=dict(n_bits=2048),
|
| 180 |
+
**astartes_kwargs,
|
| 181 |
+
)
|
| 182 |
+
train, val, test = _unpack_astartes_result(result, include_val)
|
| 183 |
+
|
| 184 |
+
case _:
|
| 185 |
+
raise RuntimeError("Unreachable code reached!")
|
| 186 |
+
|
| 187 |
+
return train, val, test
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _unpack_astartes_result(
|
| 191 |
+
result: tuple, include_val: bool
|
| 192 |
+
) -> tuple[list[list[int]], list[list[int]], list[list[int]]]:
|
| 193 |
+
"""Helper function to partition input data based on output of astartes sampler
|
| 194 |
+
|
| 195 |
+
Parameters
|
| 196 |
+
-----------
|
| 197 |
+
result: tuple
|
| 198 |
+
Output from call to astartes containing the split indices
|
| 199 |
+
include_val: bool
|
| 200 |
+
True if a validation set is included, False otherwise.
|
| 201 |
+
|
| 202 |
+
Returns
|
| 203 |
+
---------
|
| 204 |
+
train: list[int]
|
| 205 |
+
val: list[int]
|
| 206 |
+
.. important::
|
| 207 |
+
validation possibly empty
|
| 208 |
+
test: list[int]
|
| 209 |
+
"""
|
| 210 |
+
train_idxs, val_idxs, test_idxs = [], [], []
|
| 211 |
+
# astartes returns a set of lists containing the data, clusters (if applicable)
|
| 212 |
+
# and indices (always last), so we pull out the indices
|
| 213 |
+
if include_val:
|
| 214 |
+
train_idxs, val_idxs, test_idxs = result[-3], result[-2], result[-1]
|
| 215 |
+
else:
|
| 216 |
+
train_idxs, test_idxs = result[-2], result[-1]
|
| 217 |
+
return list(train_idxs), list(val_idxs), list(test_idxs)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def split_data_by_indices(
|
| 221 |
+
data: Datapoints | MulticomponentDatapoints,
|
| 222 |
+
train_indices: Iterable[Iterable[int]] | Iterable[int] | None = None,
|
| 223 |
+
val_indices: Iterable[Iterable[int]] | Iterable[int] | None = None,
|
| 224 |
+
test_indices: Iterable[Iterable[int]] | Iterable[int] | None = None,
|
| 225 |
+
):
|
| 226 |
+
"""Splits data into training, validation, and test groups based on split indices given."""
|
| 227 |
+
|
| 228 |
+
train_data = _splitter_helper(data, train_indices) if train_indices is not None else None
|
| 229 |
+
val_data = _splitter_helper(data, val_indices) if val_indices is not None else None
|
| 230 |
+
test_data = _splitter_helper(data, test_indices) if test_indices is not None else None
|
| 231 |
+
|
| 232 |
+
return train_data, val_data, test_data
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _splitter_helper(data, indices):
|
| 236 |
+
nested_component = not isinstance(data[0], (MoleculeDatapoint, ReactionDatapoint))
|
| 237 |
+
nested_split = isinstance(indices[0], Iterable)
|
| 238 |
+
|
| 239 |
+
match (nested_component, nested_split):
|
| 240 |
+
case (False, False):
|
| 241 |
+
datapoints = data
|
| 242 |
+
idxs = indices
|
| 243 |
+
return [datapoints[idx] for idx in idxs]
|
| 244 |
+
case (False, True):
|
| 245 |
+
datapoints = data
|
| 246 |
+
idxss = indices
|
| 247 |
+
return [[datapoints[idx] for idx in idxs] for idxs in idxss]
|
| 248 |
+
case (True, False):
|
| 249 |
+
datapointss = data
|
| 250 |
+
idxs = indices
|
| 251 |
+
return [[datapoints[idx] for idx in idxs] for datapoints in datapointss]
|
| 252 |
+
case (True, True):
|
| 253 |
+
datapointss = data
|
| 254 |
+
idxss = indices
|
| 255 |
+
return [
|
| 256 |
+
[[datapoints[idx] for idx in idxs] for datapoints in datapointss] for idxs in idxss
|
| 257 |
+
]
|
chemprop/exceptions.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Iterable
|
| 2 |
+
|
| 3 |
+
from chemprop.utils import pretty_shape
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class InvalidShapeError(ValueError):
|
| 7 |
+
def __init__(self, var_name: str, received: Iterable[int], expected: Iterable[int]):
|
| 8 |
+
message = (
|
| 9 |
+
f"arg '{var_name}' has incorrect shape! "
|
| 10 |
+
f"got: `{pretty_shape(received)}`. expected: `{pretty_shape(expected)}`"
|
| 11 |
+
)
|
| 12 |
+
super().__init__(message)
|
chemprop/featurizers/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import Featurizer, S, T, VectorFeaturizer, GraphFeaturizer
|
| 2 |
+
from .atom import MultiHotAtomFeaturizer, AtomFeatureMode, get_multi_hot_atom_featurizer
|
| 3 |
+
from .bond import MultiHotBondFeaturizer
|
| 4 |
+
from .molgraph import (
|
| 5 |
+
MolGraphCacheFacade,
|
| 6 |
+
MolGraphCache,
|
| 7 |
+
MolGraphCacheOnTheFly,
|
| 8 |
+
SimpleMoleculeMolGraphFeaturizer,
|
| 9 |
+
CondensedGraphOfReactionFeaturizer,
|
| 10 |
+
CGRFeaturizer,
|
| 11 |
+
RxnMode,
|
| 12 |
+
)
|
| 13 |
+
from .molecule import (
|
| 14 |
+
MorganFeaturizerMixin,
|
| 15 |
+
BinaryFeaturizerMixin,
|
| 16 |
+
CountFeaturizerMixin,
|
| 17 |
+
MorganBinaryFeaturizer,
|
| 18 |
+
MorganCountFeaturizer,
|
| 19 |
+
MoleculeFeaturizerRegistry,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
"Featurizer",
|
| 24 |
+
"S",
|
| 25 |
+
"T",
|
| 26 |
+
"VectorFeaturizer",
|
| 27 |
+
"GraphFeaturizer",
|
| 28 |
+
"MultiHotAtomFeaturizer",
|
| 29 |
+
"AtomFeatureMode",
|
| 30 |
+
"get_multi_hot_atom_featurizer",
|
| 31 |
+
"MultiHotBondFeaturizer",
|
| 32 |
+
"MolGraphCacheFacade",
|
| 33 |
+
"MolGraphCache",
|
| 34 |
+
"MolGraphCacheOnTheFly",
|
| 35 |
+
"SimpleMoleculeMolGraphFeaturizer",
|
| 36 |
+
"CondensedGraphOfReactionFeaturizer",
|
| 37 |
+
"CGRFeaturizer",
|
| 38 |
+
"RxnMode",
|
| 39 |
+
"MoleculeFeaturizer",
|
| 40 |
+
"MorganFeaturizerMixin",
|
| 41 |
+
"BinaryFeaturizerMixin",
|
| 42 |
+
"CountFeaturizerMixin",
|
| 43 |
+
"MorganBinaryFeaturizer",
|
| 44 |
+
"MorganCountFeaturizer",
|
| 45 |
+
"MoleculeFeaturizerRegistry",
|
| 46 |
+
]
|
chemprop/featurizers/atom.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Sequence
|
| 2 |
+
from enum import auto
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
from rdkit.Chem.rdchem import Atom, HybridizationType
|
| 6 |
+
|
| 7 |
+
from chemprop.utils.utils import EnumMapping
|
| 8 |
+
from chemprop.featurizers.base import VectorFeaturizer
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MultiHotAtomFeaturizer(VectorFeaturizer[Atom]):
|
| 12 |
+
"""A :class:`MultiHotAtomFeaturizer` uses a multi-hot encoding to featurize atoms.
|
| 13 |
+
|
| 14 |
+
.. seealso::
|
| 15 |
+
The class provides three default parameterization schemes:
|
| 16 |
+
|
| 17 |
+
* :meth:`MultiHotAtomFeaturizer.v1`
|
| 18 |
+
* :meth:`MultiHotAtomFeaturizer.v2`
|
| 19 |
+
* :meth:`MultiHotAtomFeaturizer.organic`
|
| 20 |
+
|
| 21 |
+
The generated atom features are ordered as follows:
|
| 22 |
+
* atomic number
|
| 23 |
+
* degree
|
| 24 |
+
* formal charge
|
| 25 |
+
* chiral tag
|
| 26 |
+
* number of hydrogens
|
| 27 |
+
* hybridization
|
| 28 |
+
* aromaticity
|
| 29 |
+
* mass
|
| 30 |
+
|
| 31 |
+
.. important::
|
| 32 |
+
Each feature, except for aromaticity and mass, includes a pad for unknown values.
|
| 33 |
+
|
| 34 |
+
Parameters
|
| 35 |
+
----------
|
| 36 |
+
atomic_nums : Sequence[int]
|
| 37 |
+
the choices for atom type denoted by atomic number. Ex: ``[4, 5, 6]`` for C, N and O.
|
| 38 |
+
degrees : Sequence[int]
|
| 39 |
+
the choices for number of bonds an atom is engaged in.
|
| 40 |
+
formal_charges : Sequence[int]
|
| 41 |
+
the choices for integer electronic charge assigned to an atom.
|
| 42 |
+
chiral_tags : Sequence[int]
|
| 43 |
+
the choices for an atom's chiral tag. See :class:`rdkit.Chem.rdchem.ChiralType` for possible integer values.
|
| 44 |
+
num_Hs : Sequence[int]
|
| 45 |
+
the choices for number of bonded hydrogen atoms.
|
| 46 |
+
hybridizations : Sequence[int]
|
| 47 |
+
the choices for an atom’s hybridization type. See :class:`rdkit.Chem.rdchem.HybridizationType` for possible integer values.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(
|
| 51 |
+
self,
|
| 52 |
+
atomic_nums: Sequence[int],
|
| 53 |
+
degrees: Sequence[int],
|
| 54 |
+
formal_charges: Sequence[int],
|
| 55 |
+
chiral_tags: Sequence[int],
|
| 56 |
+
num_Hs: Sequence[int],
|
| 57 |
+
hybridizations: Sequence[int],
|
| 58 |
+
):
|
| 59 |
+
self.atomic_nums = {j: i for i, j in enumerate(atomic_nums)}
|
| 60 |
+
self.degrees = {i: i for i in degrees}
|
| 61 |
+
self.formal_charges = {j: i for i, j in enumerate(formal_charges)}
|
| 62 |
+
self.chiral_tags = {i: i for i in chiral_tags}
|
| 63 |
+
self.num_Hs = {i: i for i in num_Hs}
|
| 64 |
+
self.hybridizations = {ht: i for i, ht in enumerate(hybridizations)}
|
| 65 |
+
|
| 66 |
+
self._subfeats: list[dict] = [
|
| 67 |
+
self.atomic_nums,
|
| 68 |
+
self.degrees,
|
| 69 |
+
self.formal_charges,
|
| 70 |
+
self.chiral_tags,
|
| 71 |
+
self.num_Hs,
|
| 72 |
+
self.hybridizations,
|
| 73 |
+
]
|
| 74 |
+
subfeat_sizes = [
|
| 75 |
+
1 + len(self.atomic_nums),
|
| 76 |
+
1 + len(self.degrees),
|
| 77 |
+
1 + len(self.formal_charges),
|
| 78 |
+
1 + len(self.chiral_tags),
|
| 79 |
+
1 + len(self.num_Hs),
|
| 80 |
+
1 + len(self.hybridizations),
|
| 81 |
+
1,
|
| 82 |
+
1,
|
| 83 |
+
]
|
| 84 |
+
self.__size = sum(subfeat_sizes)
|
| 85 |
+
|
| 86 |
+
def __len__(self) -> int:
|
| 87 |
+
return self.__size
|
| 88 |
+
|
| 89 |
+
def __call__(self, a: Atom | None) -> np.ndarray:
|
| 90 |
+
x = np.zeros(self.__size)
|
| 91 |
+
|
| 92 |
+
if a is None:
|
| 93 |
+
return x
|
| 94 |
+
|
| 95 |
+
feats = [
|
| 96 |
+
a.GetAtomicNum(),
|
| 97 |
+
a.GetTotalDegree(),
|
| 98 |
+
a.GetFormalCharge(),
|
| 99 |
+
int(a.GetChiralTag()),
|
| 100 |
+
int(a.GetTotalNumHs()),
|
| 101 |
+
a.GetHybridization(),
|
| 102 |
+
]
|
| 103 |
+
i = 0
|
| 104 |
+
for feat, choices in zip(feats, self._subfeats):
|
| 105 |
+
j = choices.get(feat, len(choices))
|
| 106 |
+
x[i + j] = 1
|
| 107 |
+
i += len(choices) + 1
|
| 108 |
+
x[i] = int(a.GetIsAromatic())
|
| 109 |
+
x[i + 1] = 0.01 * a.GetMass()
|
| 110 |
+
|
| 111 |
+
return x
|
| 112 |
+
|
| 113 |
+
def num_only(self, a: Atom) -> np.ndarray:
|
| 114 |
+
"""featurize the atom by setting only the atomic number bit"""
|
| 115 |
+
x = np.zeros(len(self))
|
| 116 |
+
|
| 117 |
+
if a is None:
|
| 118 |
+
return x
|
| 119 |
+
|
| 120 |
+
i = self.atomic_nums.get(a.GetAtomicNum(), len(self.atomic_nums))
|
| 121 |
+
x[i] = 1
|
| 122 |
+
|
| 123 |
+
return x
|
| 124 |
+
|
| 125 |
+
@classmethod
|
| 126 |
+
def v1(cls, max_atomic_num: int = 100):
|
| 127 |
+
"""The original implementation used in Chemprop V1 [1]_, [2]_.
|
| 128 |
+
|
| 129 |
+
Parameters
|
| 130 |
+
----------
|
| 131 |
+
max_atomic_num : int, default=100
|
| 132 |
+
Include a bit for all atomic numbers in the interval :math:`[1, \mathtt{max_atomic_num}]`
|
| 133 |
+
|
| 134 |
+
References
|
| 135 |
+
-----------
|
| 136 |
+
.. [1] Yang, K.; Swanson, K.; Jin, W.; Coley, C.; Eiden, P.; Gao, H.; Guzman-Perez, A.; Hopper, T.;
|
| 137 |
+
Kelley, B.; Mathea, M.; Palmer, A. "Analyzing Learned Molecular Representations for Property Prediction."
|
| 138 |
+
J. Chem. Inf. Model. 2019, 59 (8), 3370–3388. https://doi.org/10.1021/acs.jcim.9b00237
|
| 139 |
+
.. [2] Heid, E.; Greenman, K.P.; Chung, Y.; Li, S.C.; Graff, D.E.; Vermeire, F.H.; Wu, H.; Green, W.H.; McGill,
|
| 140 |
+
C.J. "Chemprop: A machine learning package for chemical property prediction." J. Chem. Inf. Model. 2024,
|
| 141 |
+
64 (1), 9–17. https://doi.org/10.1021/acs.jcim.3c01250
|
| 142 |
+
"""
|
| 143 |
+
|
| 144 |
+
return cls(
|
| 145 |
+
atomic_nums=list(range(1, max_atomic_num + 1)),
|
| 146 |
+
degrees=list(range(6)),
|
| 147 |
+
formal_charges=[-1, -2, 1, 2, 0],
|
| 148 |
+
chiral_tags=list(range(4)),
|
| 149 |
+
num_Hs=list(range(5)),
|
| 150 |
+
hybridizations=[
|
| 151 |
+
HybridizationType.SP,
|
| 152 |
+
HybridizationType.SP2,
|
| 153 |
+
HybridizationType.SP3,
|
| 154 |
+
HybridizationType.SP3D,
|
| 155 |
+
HybridizationType.SP3D2,
|
| 156 |
+
],
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
@classmethod
|
| 160 |
+
def v2(cls):
|
| 161 |
+
"""An implementation that includes an atom type bit for all elements in the first four rows of the periodic table plus iodine."""
|
| 162 |
+
|
| 163 |
+
return cls(
|
| 164 |
+
atomic_nums=list(range(1, 37)) + [53],
|
| 165 |
+
degrees=list(range(6)),
|
| 166 |
+
formal_charges=[-1, -2, 1, 2, 0],
|
| 167 |
+
chiral_tags=list(range(4)),
|
| 168 |
+
num_Hs=list(range(5)),
|
| 169 |
+
hybridizations=[
|
| 170 |
+
HybridizationType.S,
|
| 171 |
+
HybridizationType.SP,
|
| 172 |
+
HybridizationType.SP2,
|
| 173 |
+
HybridizationType.SP2D,
|
| 174 |
+
HybridizationType.SP3,
|
| 175 |
+
HybridizationType.SP3D,
|
| 176 |
+
HybridizationType.SP3D2,
|
| 177 |
+
],
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
@classmethod
|
| 181 |
+
def organic(cls):
|
| 182 |
+
r"""A specific parameterization intended for use with organic or drug-like molecules.
|
| 183 |
+
|
| 184 |
+
This parameterization features:
|
| 185 |
+
1. includes an atomic number bit only for H, B, C, N, O, F, Si, P, S, Cl, Br, and I atoms
|
| 186 |
+
2. a hybridization bit for :math:`s, sp, sp^2` and :math:`sp^3` hybridizations.
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
return cls(
|
| 190 |
+
atomic_nums=[1, 5, 6, 7, 8, 9, 14, 15, 16, 17, 35, 53],
|
| 191 |
+
degrees=list(range(6)),
|
| 192 |
+
formal_charges=[-1, -2, 1, 2, 0],
|
| 193 |
+
chiral_tags=list(range(4)),
|
| 194 |
+
num_Hs=list(range(5)),
|
| 195 |
+
hybridizations=[
|
| 196 |
+
HybridizationType.S,
|
| 197 |
+
HybridizationType.SP,
|
| 198 |
+
HybridizationType.SP2,
|
| 199 |
+
HybridizationType.SP3,
|
| 200 |
+
],
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class AtomFeatureMode(EnumMapping):
|
| 205 |
+
"""The mode of an atom is used for featurization into a `MolGraph`"""
|
| 206 |
+
|
| 207 |
+
V1 = auto()
|
| 208 |
+
V2 = auto()
|
| 209 |
+
ORGANIC = auto()
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def get_multi_hot_atom_featurizer(mode: str | AtomFeatureMode) -> MultiHotAtomFeaturizer:
|
| 213 |
+
"""Build the corresponding multi-hot atom featurizer."""
|
| 214 |
+
match AtomFeatureMode.get(mode):
|
| 215 |
+
case AtomFeatureMode.V1:
|
| 216 |
+
return MultiHotAtomFeaturizer.v1()
|
| 217 |
+
case AtomFeatureMode.V2:
|
| 218 |
+
return MultiHotAtomFeaturizer.v2()
|
| 219 |
+
case AtomFeatureMode.ORGANIC:
|
| 220 |
+
return MultiHotAtomFeaturizer.organic()
|
| 221 |
+
case _:
|
| 222 |
+
raise RuntimeError("unreachable code reached!")
|
chemprop/featurizers/base.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
from collections.abc import Sized
|
| 3 |
+
from typing import Generic, TypeVar
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from chemprop.data.molgraph import MolGraph
|
| 8 |
+
|
| 9 |
+
S = TypeVar("S")
|
| 10 |
+
T = TypeVar("T")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Featurizer(Generic[S, T]):
|
| 14 |
+
"""An :class:`Featurizer` featurizes inputs type ``S`` into outputs of
|
| 15 |
+
type ``T``."""
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
def __call__(self, input: S, *args, **kwargs) -> T:
|
| 19 |
+
"""featurize an input"""
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class VectorFeaturizer(Featurizer[S, np.ndarray], Sized):
|
| 23 |
+
...
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GraphFeaturizer(Featurizer[S, MolGraph]):
|
| 27 |
+
@property
|
| 28 |
+
@abstractmethod
|
| 29 |
+
def shape(self) -> tuple[int, int]:
|
| 30 |
+
...
|
chemprop/featurizers/bond.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Sequence
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from rdkit.Chem.rdchem import Bond, BondType
|
| 5 |
+
|
| 6 |
+
from chemprop.featurizers.base import VectorFeaturizer
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MultiHotBondFeaturizer(VectorFeaturizer[Bond]):
|
| 10 |
+
"""A :class:`MultiHotBondFeaturizer` feauturizes bonds based on the following attributes:
|
| 11 |
+
|
| 12 |
+
* ``null``-ity (i.e., is the bond ``None``?)
|
| 13 |
+
* bond type
|
| 14 |
+
* conjugated?
|
| 15 |
+
* in ring?
|
| 16 |
+
* stereochemistry
|
| 17 |
+
|
| 18 |
+
The feature vectors produced by this featurizer have the following (general) signature:
|
| 19 |
+
|
| 20 |
+
+---------------------+-----------------+--------------+
|
| 21 |
+
| slice [start, stop) | subfeature | unknown pad? |
|
| 22 |
+
+=====================+=================+==============+
|
| 23 |
+
| 0-1 | null? | N |
|
| 24 |
+
+---------------------+-----------------+--------------+
|
| 25 |
+
| 1-5 | bond type | N |
|
| 26 |
+
+---------------------+-----------------+--------------+
|
| 27 |
+
| 5-6 | conjugated? | N |
|
| 28 |
+
+---------------------+-----------------+--------------+
|
| 29 |
+
| 6-8 | in ring? | N |
|
| 30 |
+
+---------------------+-----------------+--------------+
|
| 31 |
+
| 7-14 | stereochemistry | Y |
|
| 32 |
+
+---------------------+-----------------+--------------+
|
| 33 |
+
|
| 34 |
+
**NOTE**: the above signature only applies for the default arguments, as the bond type and
|
| 35 |
+
sterochemistry slices can increase in size depending on the input arguments.
|
| 36 |
+
|
| 37 |
+
Parameters
|
| 38 |
+
----------
|
| 39 |
+
bond_types : Sequence[BondType] | None, default=[SINGLE, DOUBLE, TRIPLE, AROMATIC]
|
| 40 |
+
the known bond types
|
| 41 |
+
stereos : Sequence[int] | None, default=[0, 1, 2, 3, 4, 5]
|
| 42 |
+
the known bond stereochemistries. See [1]_ for more details
|
| 43 |
+
|
| 44 |
+
References
|
| 45 |
+
----------
|
| 46 |
+
.. [1] https://www.rdkit.org/docs/source/rdkit.Chem.rdchem.html#rdkit.Chem.rdchem.BondStereo.values
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self, bond_types: Sequence[BondType] | None = None, stereos: Sequence[int] | None = None
|
| 51 |
+
):
|
| 52 |
+
self.bond_types = bond_types or [
|
| 53 |
+
BondType.SINGLE,
|
| 54 |
+
BondType.DOUBLE,
|
| 55 |
+
BondType.TRIPLE,
|
| 56 |
+
BondType.AROMATIC,
|
| 57 |
+
]
|
| 58 |
+
self.stereo = stereos or range(6)
|
| 59 |
+
|
| 60 |
+
def __len__(self):
|
| 61 |
+
return 1 + len(self.bond_types) + 2 + (len(self.stereo) + 1)
|
| 62 |
+
|
| 63 |
+
def __call__(self, b: Bond) -> np.ndarray:
|
| 64 |
+
x = np.zeros(len(self), int)
|
| 65 |
+
|
| 66 |
+
if b is None:
|
| 67 |
+
x[0] = 1
|
| 68 |
+
return x
|
| 69 |
+
|
| 70 |
+
i = 1
|
| 71 |
+
bond_type = b.GetBondType()
|
| 72 |
+
bt_bit, size = self.one_hot_index(bond_type, self.bond_types)
|
| 73 |
+
if bt_bit != size:
|
| 74 |
+
x[i + bt_bit] = 1
|
| 75 |
+
i += size - 1
|
| 76 |
+
|
| 77 |
+
x[i] = int(b.GetIsConjugated())
|
| 78 |
+
x[i + 1] = int(b.IsInRing())
|
| 79 |
+
i += 2
|
| 80 |
+
|
| 81 |
+
stereo_bit, _ = self.one_hot_index(int(b.GetStereo()), self.stereo)
|
| 82 |
+
x[i + stereo_bit] = 1
|
| 83 |
+
|
| 84 |
+
return x
|
| 85 |
+
|
| 86 |
+
@classmethod
|
| 87 |
+
def one_hot_index(cls, x, xs: Sequence) -> tuple[int, int]:
|
| 88 |
+
"""Returns a tuple of the index of ``x`` in ``xs`` and ``len(xs) + 1`` if ``x`` is in ``xs``.
|
| 89 |
+
Otherwise, returns a tuple with ``len(xs)`` and ``len(xs) + 1``."""
|
| 90 |
+
n = len(xs)
|
| 91 |
+
|
| 92 |
+
return xs.index(x) if x in xs else n, n + 1
|
chemprop/featurizers/molecule.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from rdkit import Chem
|
| 3 |
+
from rdkit.Chem import Mol
|
| 4 |
+
from rdkit.Chem.rdFingerprintGenerator import GetMorganGenerator
|
| 5 |
+
|
| 6 |
+
from chemprop.featurizers.base import VectorFeaturizer
|
| 7 |
+
from chemprop.utils import ClassRegistry
|
| 8 |
+
|
| 9 |
+
MoleculeFeaturizerRegistry = ClassRegistry[VectorFeaturizer[Mol]]()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MorganFeaturizerMixin:
|
| 13 |
+
def __init__(self, radius: int = 2, length: int = 2048, include_chirality: bool = True):
|
| 14 |
+
if radius < 0:
|
| 15 |
+
raise ValueError(f"arg 'radius' must be >= 0! got: {radius}")
|
| 16 |
+
|
| 17 |
+
self.length = length
|
| 18 |
+
self.F = GetMorganGenerator(
|
| 19 |
+
radius=radius, fpSize=length, includeChirality=include_chirality
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
def __len__(self) -> int:
|
| 23 |
+
return self.length
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BinaryFeaturizerMixin:
|
| 27 |
+
def __call__(self, mol: Chem.Mol) -> np.ndarray:
|
| 28 |
+
return self.F.GetFingerprintAsNumPy(mol)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CountFeaturizerMixin:
|
| 32 |
+
def __call__(self, mol: Chem.Mol) -> np.ndarray:
|
| 33 |
+
return self.F.GetCountFingerprintAsNumPy(mol).astype(np.int32)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@MoleculeFeaturizerRegistry("morgan_binary")
|
| 37 |
+
class MorganBinaryFeaturizer(MorganFeaturizerMixin, BinaryFeaturizerMixin, VectorFeaturizer[Mol]):
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@MoleculeFeaturizerRegistry("morgan_count")
|
| 42 |
+
class MorganCountFeaturizer(MorganFeaturizerMixin, CountFeaturizerMixin, VectorFeaturizer[Mol]):
|
| 43 |
+
pass
|
chemprop/featurizers/molgraph/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .cache import MolGraphCacheFacade, MolGraphCache, MolGraphCacheOnTheFly
|
| 2 |
+
from .molecule import SimpleMoleculeMolGraphFeaturizer
|
| 3 |
+
from .reaction import CondensedGraphOfReactionFeaturizer, CGRFeaturizer, RxnMode
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"MolGraphCacheFacade",
|
| 7 |
+
"MolGraphCache",
|
| 8 |
+
"MolGraphCacheOnTheFly",
|
| 9 |
+
"SimpleMoleculeMolGraphFeaturizer",
|
| 10 |
+
"CondensedGraphOfReactionFeaturizer",
|
| 11 |
+
"CGRFeaturizer",
|
| 12 |
+
"RxnMode",
|
| 13 |
+
]
|
chemprop/featurizers/molgraph/cache.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
from collections.abc import Sequence
|
| 3 |
+
from typing import Generic, Iterable
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from chemprop.featurizers.base import S, Featurizer
|
| 8 |
+
from chemprop.data.molgraph import MolGraph
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MolGraphCacheFacade(Sequence[MolGraph], Generic[S]):
|
| 12 |
+
"""
|
| 13 |
+
A :class:`MolGraphCacheFacade` provided an interface for caching
|
| 14 |
+
:class:`~chemprop.data.molgraph.MolGraph`\s.
|
| 15 |
+
|
| 16 |
+
.. note::
|
| 17 |
+
This class only provides a facade for a cached dataset, but it _does not guarantee_
|
| 18 |
+
whether the underlying data is truly cached.
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
Parameters
|
| 22 |
+
----------
|
| 23 |
+
inputs : Iterable[S]
|
| 24 |
+
The inputs to be featurized.
|
| 25 |
+
V_fs : Iterable[np.ndarray]
|
| 26 |
+
The node features for each input.
|
| 27 |
+
E_fs : Iterable[np.ndarray]
|
| 28 |
+
The edge features for each input.
|
| 29 |
+
featurizer : Featurizer[S, MolGraph]
|
| 30 |
+
The featurizer with which to generate the
|
| 31 |
+
:class:`~chemprop.data.molgraph.MolGraph`\s.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
@abstractmethod
|
| 35 |
+
def __init__(
|
| 36 |
+
self,
|
| 37 |
+
inputs: Iterable[S],
|
| 38 |
+
V_fs: Iterable[np.ndarray],
|
| 39 |
+
E_fs: Iterable[np.ndarray],
|
| 40 |
+
featurizer: Featurizer[S, MolGraph],
|
| 41 |
+
):
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class MolGraphCache(MolGraphCacheFacade):
|
| 46 |
+
"""
|
| 47 |
+
A :class:`MolGraphCache` precomputes the corresponding
|
| 48 |
+
:class:`~chemprop.data.molgraph.MolGraph`\s and caches them in memory.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
inputs: Iterable[S],
|
| 54 |
+
V_fs: Iterable[np.ndarray | None],
|
| 55 |
+
E_fs: Iterable[np.ndarray | None],
|
| 56 |
+
featurizer: Featurizer[S, MolGraph],
|
| 57 |
+
):
|
| 58 |
+
self._mgs = [featurizer(input, V_f, E_f) for input, V_f, E_f in zip(inputs, V_fs, E_fs)]
|
| 59 |
+
|
| 60 |
+
def __len__(self) -> int:
|
| 61 |
+
return len(self._mgs)
|
| 62 |
+
|
| 63 |
+
def __getitem__(self, index: int) -> MolGraph:
|
| 64 |
+
return self._mgs[index]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class MolGraphCacheOnTheFly(MolGraphCacheFacade):
|
| 68 |
+
"""
|
| 69 |
+
A :class:`MolGraphCacheOnTheFly` computes the corresponding
|
| 70 |
+
:class:`~chemprop.data.molgraph.MolGraph`\s as they are requested.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
inputs: Iterable[S],
|
| 76 |
+
V_fs: Iterable[np.ndarray | None],
|
| 77 |
+
E_fs: Iterable[np.ndarray | None],
|
| 78 |
+
featurizer: Featurizer[S, MolGraph],
|
| 79 |
+
):
|
| 80 |
+
self._inputs = list(inputs)
|
| 81 |
+
self._V_fs = list(V_fs)
|
| 82 |
+
self._E_fs = list(E_fs)
|
| 83 |
+
self._featurizer = featurizer
|
| 84 |
+
|
| 85 |
+
def __len__(self) -> int:
|
| 86 |
+
return len(self._inputs)
|
| 87 |
+
|
| 88 |
+
def __getitem__(self, index: int) -> MolGraph:
|
| 89 |
+
return self._featurizer(self._inputs[index], self._V_fs[index], self._E_fs[index])
|
chemprop/featurizers/molgraph/mixins.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
|
| 3 |
+
from rdkit.Chem.rdchem import Atom, Bond
|
| 4 |
+
|
| 5 |
+
from chemprop.featurizers.base import VectorFeaturizer
|
| 6 |
+
from chemprop.featurizers.atom import MultiHotAtomFeaturizer
|
| 7 |
+
from chemprop.featurizers.bond import MultiHotBondFeaturizer
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class _MolGraphFeaturizerMixin:
|
| 12 |
+
atom_featurizer: VectorFeaturizer[Atom] = field(default_factory=MultiHotAtomFeaturizer.v2)
|
| 13 |
+
bond_featurizer: VectorFeaturizer[Bond] = field(default_factory=MultiHotBondFeaturizer)
|
| 14 |
+
|
| 15 |
+
def __post_init__(self):
|
| 16 |
+
self.atom_fdim = len(self.atom_featurizer)
|
| 17 |
+
self.bond_fdim = len(self.bond_featurizer)
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def shape(self) -> tuple[int, int]:
|
| 21 |
+
"""the feature dimension of the atoms and bonds, respectively, of `MolGraph`s generated by
|
| 22 |
+
this featurizer"""
|
| 23 |
+
return self.atom_fdim, self.bond_fdim
|
chemprop/featurizers/molgraph/molecule.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import InitVar, dataclass
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
from rdkit import Chem
|
| 5 |
+
from rdkit.Chem import Mol
|
| 6 |
+
|
| 7 |
+
from chemprop.data.molgraph import MolGraph
|
| 8 |
+
from chemprop.featurizers.base import GraphFeaturizer
|
| 9 |
+
from chemprop.featurizers.molgraph.mixins import _MolGraphFeaturizerMixin
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class SimpleMoleculeMolGraphFeaturizer(_MolGraphFeaturizerMixin, GraphFeaturizer[Mol]):
|
| 14 |
+
"""A :class:`SimpleMoleculeMolGraphFeaturizer` is the default implementation of a
|
| 15 |
+
:class:`MoleculeMolGraphFeaturizer`
|
| 16 |
+
|
| 17 |
+
Parameters
|
| 18 |
+
----------
|
| 19 |
+
atom_featurizer : AtomFeaturizer, default=MultiHotAtomFeaturizer()
|
| 20 |
+
the featurizer with which to calculate feature representations of the atoms in a given
|
| 21 |
+
molecule
|
| 22 |
+
bond_featurizer : BondFeaturizer, default=MultiHotBondFeaturizer()
|
| 23 |
+
the featurizer with which to calculate feature representations of the bonds in a given
|
| 24 |
+
molecule
|
| 25 |
+
extra_atom_fdim : int, default=0
|
| 26 |
+
the dimension of the additional features that will be concatenated onto the calculated
|
| 27 |
+
features of each atom
|
| 28 |
+
extra_bond_fdim : int, default=0
|
| 29 |
+
the dimension of the additional features that will be concatenated onto the calculated
|
| 30 |
+
features of each bond
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
extra_atom_fdim: InitVar[int] = 0
|
| 34 |
+
extra_bond_fdim: InitVar[int] = 0
|
| 35 |
+
|
| 36 |
+
def __post_init__(self, extra_atom_fdim: int = 0, extra_bond_fdim: int = 0):
|
| 37 |
+
super().__post_init__()
|
| 38 |
+
|
| 39 |
+
self.extra_atom_fdim = extra_atom_fdim
|
| 40 |
+
self.extra_bond_fdim = extra_bond_fdim
|
| 41 |
+
self.atom_fdim += self.extra_atom_fdim
|
| 42 |
+
self.bond_fdim += self.extra_bond_fdim
|
| 43 |
+
|
| 44 |
+
def __call__(
|
| 45 |
+
self,
|
| 46 |
+
mol: Chem.Mol,
|
| 47 |
+
atom_features_extra: np.ndarray | None = None,
|
| 48 |
+
bond_features_extra: np.ndarray | None = None,
|
| 49 |
+
) -> MolGraph:
|
| 50 |
+
n_atoms = mol.GetNumAtoms()
|
| 51 |
+
n_bonds = mol.GetNumBonds()
|
| 52 |
+
|
| 53 |
+
if atom_features_extra is not None and len(atom_features_extra) != n_atoms:
|
| 54 |
+
raise ValueError(
|
| 55 |
+
"Input molecule must have same number of atoms as `len(atom_features_extra)`!"
|
| 56 |
+
f"got: {n_atoms} and {len(atom_features_extra)}, respectively"
|
| 57 |
+
)
|
| 58 |
+
if bond_features_extra is not None and len(bond_features_extra) != n_bonds:
|
| 59 |
+
raise ValueError(
|
| 60 |
+
"Input molecule must have same number of bonds as `len(bond_features_extra)`!"
|
| 61 |
+
f"got: {n_bonds} and {len(bond_features_extra)}, respectively"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
if n_atoms == 0:
|
| 65 |
+
V = np.zeros((1, self.atom_fdim), dtype=np.single)
|
| 66 |
+
else:
|
| 67 |
+
V = np.array([self.atom_featurizer(a) for a in mol.GetAtoms()], dtype=np.single)
|
| 68 |
+
E = np.empty((2 * n_bonds, self.bond_fdim))
|
| 69 |
+
edge_index = [[], []]
|
| 70 |
+
|
| 71 |
+
if atom_features_extra is not None:
|
| 72 |
+
V = np.hstack((V, atom_features_extra))
|
| 73 |
+
|
| 74 |
+
i = 0
|
| 75 |
+
for u in range(n_atoms):
|
| 76 |
+
for v in range(u + 1, n_atoms):
|
| 77 |
+
bond = mol.GetBondBetweenAtoms(u, v)
|
| 78 |
+
if bond is None:
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
x_e = self.bond_featurizer(bond)
|
| 82 |
+
if bond_features_extra is not None:
|
| 83 |
+
x_e = np.concatenate((x_e, bond_features_extra[bond.GetIdx()]), dtype=np.single)
|
| 84 |
+
|
| 85 |
+
E[i : i + 2] = x_e
|
| 86 |
+
|
| 87 |
+
edge_index[0].extend([u, v])
|
| 88 |
+
edge_index[1].extend([v, u])
|
| 89 |
+
|
| 90 |
+
i += 2
|
| 91 |
+
|
| 92 |
+
rev_edge_index = np.arange(len(E)).reshape(-1, 2)[:, ::-1].ravel()
|
| 93 |
+
edge_index = np.array(edge_index, int)
|
| 94 |
+
|
| 95 |
+
return MolGraph(V, E, edge_index, rev_edge_index)
|
chemprop/featurizers/molgraph/reaction.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import InitVar, dataclass
|
| 2 |
+
from enum import auto
|
| 3 |
+
from typing import Iterable, Sequence, TypeAlias
|
| 4 |
+
import warnings
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from rdkit import Chem
|
| 8 |
+
from rdkit.Chem.rdchem import Bond, Mol
|
| 9 |
+
from chemprop.featurizers.base import GraphFeaturizer
|
| 10 |
+
|
| 11 |
+
from chemprop.types import Rxn
|
| 12 |
+
from chemprop.data.molgraph import MolGraph
|
| 13 |
+
from chemprop.featurizers.molgraph.mixins import _MolGraphFeaturizerMixin
|
| 14 |
+
from chemprop.utils.utils import EnumMapping
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class RxnMode(EnumMapping):
|
| 18 |
+
"""The mode by which a reaction should be featurized into a `MolGraph`"""
|
| 19 |
+
|
| 20 |
+
REAC_PROD = auto()
|
| 21 |
+
"""concatenate the reactant features with the product features."""
|
| 22 |
+
REAC_PROD_BALANCE = auto()
|
| 23 |
+
"""concatenate the reactant features with the products feature and balances imbalanced
|
| 24 |
+
reactions"""
|
| 25 |
+
REAC_DIFF = auto()
|
| 26 |
+
"""concatenates the reactant features with the difference in features between reactants and
|
| 27 |
+
products"""
|
| 28 |
+
REAC_DIFF_BALANCE = auto()
|
| 29 |
+
"""concatenates the reactant features with the difference in features between reactants and
|
| 30 |
+
product and balances imbalanced reactions"""
|
| 31 |
+
PROD_DIFF = auto()
|
| 32 |
+
"""concatenates the product features with the difference in features between reactants and
|
| 33 |
+
products"""
|
| 34 |
+
PROD_DIFF_BALANCE = auto()
|
| 35 |
+
"""concatenates the product features with the difference in features between reactants and
|
| 36 |
+
products and balances imbalanced reactions"""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class CondensedGraphOfReactionFeaturizer(_MolGraphFeaturizerMixin, GraphFeaturizer[Rxn]):
|
| 41 |
+
"""A :class:`CondensedGraphOfReactionFeaturizer` featurizes reactions using the condensed
|
| 42 |
+
reaction graph method utilized in [1]_
|
| 43 |
+
|
| 44 |
+
**NOTE**: This class *does not* accept a :class:`AtomFeaturizer` instance. This is because
|
| 45 |
+
it requries the :meth:`num_only()` method, which is only implemented in the concrete
|
| 46 |
+
:class:`AtomFeaturizer` class
|
| 47 |
+
|
| 48 |
+
Parameters
|
| 49 |
+
----------
|
| 50 |
+
atom_featurizer : AtomFeaturizer, default=AtomFeaturizer()
|
| 51 |
+
the featurizer with which to calculate feature representations of the atoms in a given
|
| 52 |
+
molecule
|
| 53 |
+
bond_featurizer : BondFeaturizerBase, default=BondFeaturizer()
|
| 54 |
+
the featurizer with which to calculate feature representations of the bonds in a given
|
| 55 |
+
molecule
|
| 56 |
+
mode_ : Union[str, ReactionMode], default=ReactionMode.REAC_DIFF
|
| 57 |
+
the mode by which to featurize the reaction as either the string code or enum value
|
| 58 |
+
|
| 59 |
+
References
|
| 60 |
+
----------
|
| 61 |
+
.. [1] Heid, E.; Green, W.H. "Machine Learning of Reaction Properties via Learned
|
| 62 |
+
Representations of the Condensed Graph of Reaction." J. Chem. Inf. Model. 2022, 62,
|
| 63 |
+
2101-2110. https://doi.org/10.1021/acs.jcim.1c00975
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
mode_: InitVar[str | RxnMode] = RxnMode.REAC_DIFF
|
| 67 |
+
|
| 68 |
+
def __post_init__(self, mode_: str | RxnMode):
|
| 69 |
+
super().__post_init__()
|
| 70 |
+
|
| 71 |
+
self.mode = mode_
|
| 72 |
+
self.atom_fdim += len(self.atom_featurizer) - len(self.atom_featurizer.atomic_nums) - 1
|
| 73 |
+
self.bond_fdim *= 2
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def mode(self) -> RxnMode:
|
| 77 |
+
return self.__mode
|
| 78 |
+
|
| 79 |
+
@mode.setter
|
| 80 |
+
def mode(self, m: str | RxnMode):
|
| 81 |
+
self.__mode = RxnMode.get(m)
|
| 82 |
+
|
| 83 |
+
def __call__(
|
| 84 |
+
self,
|
| 85 |
+
rxn: tuple[Chem.Mol, Chem.Mol],
|
| 86 |
+
atom_features_extra: np.ndarray | None = None,
|
| 87 |
+
bond_features_extra: np.ndarray | None = None,
|
| 88 |
+
) -> MolGraph:
|
| 89 |
+
"""Featurize the input reaction into a molecular graph
|
| 90 |
+
|
| 91 |
+
Parameters
|
| 92 |
+
----------
|
| 93 |
+
rxn : Rxn
|
| 94 |
+
a 2-tuple of atom-mapped rdkit molecules, where the 0th element is the reactant and the
|
| 95 |
+
1st element is the product
|
| 96 |
+
atom_features_extra : np.ndarray | None, default=None
|
| 97 |
+
*UNSUPPORTED* maintained only to maintain parity with the method signature of the
|
| 98 |
+
`MoleculeFeaturizer`
|
| 99 |
+
bond_features_extra : np.ndarray | None, default=None
|
| 100 |
+
*UNSUPPORTED* maintained only to maintain parity with the method signature of the
|
| 101 |
+
`MoleculeFeaturizer`
|
| 102 |
+
|
| 103 |
+
Returns
|
| 104 |
+
-------
|
| 105 |
+
MolGraph
|
| 106 |
+
the molecular graph of the reaction
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
if atom_features_extra is not None:
|
| 110 |
+
warnings.warn("'atom_features_extra' is currently unsupported for reactions")
|
| 111 |
+
if bond_features_extra is not None:
|
| 112 |
+
warnings.warn("'bond_features_extra' is currently unsupported for reactions")
|
| 113 |
+
|
| 114 |
+
reac, pdt = rxn
|
| 115 |
+
r2p_idx_map, pdt_idxs, reac_idxs = self.map_reac_to_prod(reac, pdt)
|
| 116 |
+
|
| 117 |
+
V = self._calc_node_feature_matrix(reac, pdt, r2p_idx_map, pdt_idxs, reac_idxs)
|
| 118 |
+
E = []
|
| 119 |
+
edge_index = [[], []]
|
| 120 |
+
|
| 121 |
+
n_atoms_tot = len(V)
|
| 122 |
+
n_atoms_reac = reac.GetNumAtoms()
|
| 123 |
+
|
| 124 |
+
i = 0
|
| 125 |
+
for u in range(n_atoms_tot):
|
| 126 |
+
for v in range(u + 1, n_atoms_tot):
|
| 127 |
+
b_reac, b_prod = self._get_bonds(
|
| 128 |
+
reac, pdt, r2p_idx_map, pdt_idxs, n_atoms_reac, u, v
|
| 129 |
+
)
|
| 130 |
+
if b_reac is None and b_prod is None:
|
| 131 |
+
continue
|
| 132 |
+
|
| 133 |
+
x_e = self._calc_edge_feature(b_reac, b_prod)
|
| 134 |
+
E.extend([x_e, x_e])
|
| 135 |
+
edge_index[0].extend([u, v])
|
| 136 |
+
edge_index[1].extend([v, u])
|
| 137 |
+
|
| 138 |
+
i += 2
|
| 139 |
+
|
| 140 |
+
E = np.array(E)
|
| 141 |
+
rev_edge_index = np.arange(len(E)).reshape(-1, 2)[:, ::-1].ravel()
|
| 142 |
+
edge_index = np.array(edge_index, int)
|
| 143 |
+
|
| 144 |
+
return MolGraph(V, E, edge_index, rev_edge_index)
|
| 145 |
+
|
| 146 |
+
def _calc_node_feature_matrix(
|
| 147 |
+
self,
|
| 148 |
+
rct: Mol,
|
| 149 |
+
pdt: Mol,
|
| 150 |
+
r2p_idx_map: dict[int, int],
|
| 151 |
+
pdt_idxs: Iterable[int],
|
| 152 |
+
reac_idxs: Iterable[int],
|
| 153 |
+
) -> np.ndarray:
|
| 154 |
+
"""Calculate the node feature matrix for the reaction"""
|
| 155 |
+
X_v_r1 = np.array([self.atom_featurizer(a) for a in rct.GetAtoms()])
|
| 156 |
+
X_v_p2 = np.array([self.atom_featurizer(pdt.GetAtomWithIdx(i)) for i in pdt_idxs])
|
| 157 |
+
X_v_p2 = X_v_p2.reshape(-1, X_v_r1.shape[1])
|
| 158 |
+
|
| 159 |
+
if self.mode in [RxnMode.REAC_DIFF, RxnMode.PROD_DIFF, RxnMode.REAC_PROD]:
|
| 160 |
+
# Reactant:
|
| 161 |
+
# (1) regular features for each atom in the reactants
|
| 162 |
+
# (2) zero features for each atom that's only in the products
|
| 163 |
+
X_v_r2 = [self.atom_featurizer.num_only(pdt.GetAtomWithIdx(i)) for i in pdt_idxs]
|
| 164 |
+
X_v_r2 = np.array(X_v_r2).reshape(-1, X_v_r1.shape[1])
|
| 165 |
+
|
| 166 |
+
# Product:
|
| 167 |
+
# (1) either (a) product-side features for each atom in both
|
| 168 |
+
# or (b) zero features for each atom only in the reatants
|
| 169 |
+
# (2) regular features for each atom only in the products
|
| 170 |
+
X_v_p1 = np.array(
|
| 171 |
+
[
|
| 172 |
+
(
|
| 173 |
+
self.atom_featurizer(pdt.GetAtomWithIdx(r2p_idx_map[a.GetIdx()]))
|
| 174 |
+
if a.GetIdx() not in reac_idxs
|
| 175 |
+
else self.atom_featurizer.num_only(a)
|
| 176 |
+
)
|
| 177 |
+
for a in rct.GetAtoms()
|
| 178 |
+
]
|
| 179 |
+
)
|
| 180 |
+
else:
|
| 181 |
+
# Reactant:
|
| 182 |
+
# (1) regular features for each atom in the reactants
|
| 183 |
+
# (2) regular features for each atom only in the products
|
| 184 |
+
X_v_r2 = [self.atom_featurizer(pdt.GetAtomWithIdx(i)) for i in pdt_idxs]
|
| 185 |
+
X_v_r2 = np.array(X_v_r2).reshape(-1, X_v_r1.shape[1])
|
| 186 |
+
|
| 187 |
+
# Product:
|
| 188 |
+
# (1) either (a) product-side features for each atom in both
|
| 189 |
+
# or (b) reactant-side features for each atom only in the reatants
|
| 190 |
+
# (2) regular features for each atom only in the products
|
| 191 |
+
X_v_p1 = np.array(
|
| 192 |
+
[
|
| 193 |
+
(
|
| 194 |
+
self.atom_featurizer(pdt.GetAtomWithIdx(r2p_idx_map[a.GetIdx()]))
|
| 195 |
+
if a.GetIdx() not in reac_idxs
|
| 196 |
+
else self.atom_featurizer(a)
|
| 197 |
+
)
|
| 198 |
+
for a in rct.GetAtoms()
|
| 199 |
+
]
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
X_v_r = np.concatenate((X_v_r1, X_v_r2))
|
| 203 |
+
X_v_p = np.concatenate((X_v_p1, X_v_p2))
|
| 204 |
+
|
| 205 |
+
m = min(len(X_v_r), len(X_v_p))
|
| 206 |
+
|
| 207 |
+
if self.mode in [RxnMode.REAC_PROD, RxnMode.REAC_PROD_BALANCE]:
|
| 208 |
+
X_v = np.hstack((X_v_r[:m], X_v_p[:m, len(self.atom_featurizer.atomic_nums) + 1 :]))
|
| 209 |
+
else:
|
| 210 |
+
X_v_d = X_v_p[:m] - X_v_r[:m]
|
| 211 |
+
if self.mode in [RxnMode.REAC_DIFF, RxnMode.REAC_DIFF_BALANCE]:
|
| 212 |
+
X_v = np.hstack((X_v_r[:m], X_v_d[:m, len(self.atom_featurizer.atomic_nums) + 1 :]))
|
| 213 |
+
else:
|
| 214 |
+
X_v = np.hstack((X_v_p[:m], X_v_d[:m, len(self.atom_featurizer.atomic_nums) + 1 :]))
|
| 215 |
+
|
| 216 |
+
return X_v
|
| 217 |
+
|
| 218 |
+
def _get_bonds(
|
| 219 |
+
self,
|
| 220 |
+
rct: Bond,
|
| 221 |
+
pdt: Bond,
|
| 222 |
+
ri2pj: dict[int, int],
|
| 223 |
+
pids: Sequence[int],
|
| 224 |
+
n_atoms_r: int,
|
| 225 |
+
u: int,
|
| 226 |
+
v: int,
|
| 227 |
+
) -> tuple[Bond, Bond]:
|
| 228 |
+
"""get the corresponding reactant- and product-side bond, respectively, betweeen atoms `u` and `v`"""
|
| 229 |
+
if u >= n_atoms_r and v >= n_atoms_r:
|
| 230 |
+
b_prod = pdt.GetBondBetweenAtoms(pids[u - n_atoms_r], pids[v - n_atoms_r])
|
| 231 |
+
|
| 232 |
+
if self.mode in [
|
| 233 |
+
RxnMode.REAC_PROD_BALANCE,
|
| 234 |
+
RxnMode.REAC_DIFF_BALANCE,
|
| 235 |
+
RxnMode.PROD_DIFF_BALANCE,
|
| 236 |
+
]:
|
| 237 |
+
b_reac = b_prod
|
| 238 |
+
else:
|
| 239 |
+
b_reac = None
|
| 240 |
+
elif u < n_atoms_r and v >= n_atoms_r: # One atom only in product
|
| 241 |
+
b_reac = None
|
| 242 |
+
|
| 243 |
+
if u in ri2pj:
|
| 244 |
+
b_prod = pdt.GetBondBetweenAtoms(ri2pj[u], pids[v - n_atoms_r])
|
| 245 |
+
else: # Atom atom only in reactant, the other only in product
|
| 246 |
+
b_prod = None
|
| 247 |
+
else:
|
| 248 |
+
b_reac = rct.GetBondBetweenAtoms(u, v)
|
| 249 |
+
|
| 250 |
+
if u in ri2pj and v in ri2pj: # Both atoms in both reactant and product
|
| 251 |
+
b_prod = pdt.GetBondBetweenAtoms(ri2pj[u], ri2pj[v])
|
| 252 |
+
elif self.mode in [
|
| 253 |
+
RxnMode.REAC_PROD_BALANCE,
|
| 254 |
+
RxnMode.REAC_DIFF_BALANCE,
|
| 255 |
+
RxnMode.PROD_DIFF_BALANCE,
|
| 256 |
+
]:
|
| 257 |
+
b_prod = None if (u in ri2pj or v in ri2pj) else b_reac
|
| 258 |
+
else: # One or both atoms only in reactant
|
| 259 |
+
b_prod = None
|
| 260 |
+
|
| 261 |
+
return b_reac, b_prod
|
| 262 |
+
|
| 263 |
+
def _calc_edge_feature(self, b_reac: Bond, b_pdt: Bond):
|
| 264 |
+
"""Calculate the global features of the two bonds"""
|
| 265 |
+
x_e_r = self.bond_featurizer(b_reac)
|
| 266 |
+
x_e_p = self.bond_featurizer(b_pdt)
|
| 267 |
+
x_e_d = x_e_p - x_e_r
|
| 268 |
+
|
| 269 |
+
if self.mode in [RxnMode.REAC_PROD, RxnMode.REAC_PROD_BALANCE]:
|
| 270 |
+
x_e = np.hstack((x_e_r, x_e_p))
|
| 271 |
+
elif self.mode in [RxnMode.REAC_DIFF, RxnMode.REAC_DIFF_BALANCE]:
|
| 272 |
+
x_e = np.hstack((x_e_r, x_e_d))
|
| 273 |
+
else:
|
| 274 |
+
x_e = np.hstack((x_e_p, x_e_d))
|
| 275 |
+
|
| 276 |
+
return x_e
|
| 277 |
+
|
| 278 |
+
@classmethod
|
| 279 |
+
def map_reac_to_prod(
|
| 280 |
+
cls, reacs: Chem.Mol, pdts: Chem.Mol
|
| 281 |
+
) -> tuple[dict[int, int], list[int], list[int]]:
|
| 282 |
+
"""Map atom indices between corresponding atoms in the reactant and product molecules
|
| 283 |
+
|
| 284 |
+
Parameters
|
| 285 |
+
----------
|
| 286 |
+
reacs : Chem.Mol
|
| 287 |
+
An RDKit molecule of the reactants
|
| 288 |
+
pdts : Chem.Mol
|
| 289 |
+
An RDKit molecule of the products
|
| 290 |
+
|
| 291 |
+
Returns
|
| 292 |
+
-------
|
| 293 |
+
ri2pi : dict[int, int]
|
| 294 |
+
A dictionary of corresponding atom indices from reactant atoms to product atoms
|
| 295 |
+
pdt_idxs : list[int]
|
| 296 |
+
atom indices of poduct atoms
|
| 297 |
+
rct_idxs : list[int]
|
| 298 |
+
atom indices of reactant atoms
|
| 299 |
+
"""
|
| 300 |
+
pdt_idxs = []
|
| 301 |
+
mapno2pj = {}
|
| 302 |
+
reac_atommap_nums = {a.GetAtomMapNum() for a in reacs.GetAtoms()}
|
| 303 |
+
|
| 304 |
+
for a in pdts.GetAtoms():
|
| 305 |
+
map_num = a.GetAtomMapNum()
|
| 306 |
+
j = a.GetIdx()
|
| 307 |
+
|
| 308 |
+
if map_num > 0:
|
| 309 |
+
mapno2pj[map_num] = j
|
| 310 |
+
if map_num not in reac_atommap_nums:
|
| 311 |
+
pdt_idxs.append(j)
|
| 312 |
+
else:
|
| 313 |
+
pdt_idxs.append(j)
|
| 314 |
+
|
| 315 |
+
rct_idxs = []
|
| 316 |
+
r2p_idx_map = {}
|
| 317 |
+
|
| 318 |
+
for a in reacs.GetAtoms():
|
| 319 |
+
map_num = a.GetAtomMapNum()
|
| 320 |
+
i = a.GetIdx()
|
| 321 |
+
|
| 322 |
+
if map_num > 0:
|
| 323 |
+
try:
|
| 324 |
+
r2p_idx_map[i] = mapno2pj[map_num]
|
| 325 |
+
except KeyError:
|
| 326 |
+
rct_idxs.append(i)
|
| 327 |
+
else:
|
| 328 |
+
rct_idxs.append(i)
|
| 329 |
+
|
| 330 |
+
return r2p_idx_map, pdt_idxs, rct_idxs
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
CGRFeaturizer: TypeAlias = CondensedGraphOfReactionFeaturizer
|
chemprop/models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .model import MPNN
|
| 2 |
+
from .multi import MulticomponentMPNN
|
| 3 |
+
from .utils import load_model, save_model
|
| 4 |
+
|
| 5 |
+
__all__ = ["MPNN", "MulticomponentMPNN", "load_model", "save_model"]
|
chemprop/models/model.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Iterable
|
| 4 |
+
|
| 5 |
+
from lightning import pytorch as pl
|
| 6 |
+
import torch
|
| 7 |
+
from torch import nn, Tensor, optim
|
| 8 |
+
|
| 9 |
+
from chemprop.data import TrainingBatch, BatchMolGraph
|
| 10 |
+
from chemprop.nn.metrics import Metric
|
| 11 |
+
from chemprop.nn import MessagePassing, Aggregation, Predictor, LossFunction
|
| 12 |
+
from chemprop.schedulers import NoamLR
|
| 13 |
+
from chemprop.nn.transforms import ScaleTransform
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class MPNN(pl.LightningModule):
|
| 17 |
+
r"""An :class:`MPNN` is a sequence of message passing layers, an aggregation routine, and a
|
| 18 |
+
predictor routine.
|
| 19 |
+
|
| 20 |
+
The first two modules calculate learned fingerprints from an input molecule
|
| 21 |
+
reaction graph, and the final module takes these learned fingerprints as input to calculate a
|
| 22 |
+
final prediction. I.e., the following operation:
|
| 23 |
+
|
| 24 |
+
.. math::
|
| 25 |
+
\mathtt{MPNN}(\mathcal{G}) =
|
| 26 |
+
\mathtt{predictor}(\mathtt{agg}(\mathtt{message\_passing}(\mathcal{G})))
|
| 27 |
+
|
| 28 |
+
The full model is trained end-to-end.
|
| 29 |
+
|
| 30 |
+
Parameters
|
| 31 |
+
----------
|
| 32 |
+
message_passing : MessagePassing
|
| 33 |
+
the message passing block to use to calculate learned fingerprints
|
| 34 |
+
agg : Aggregation
|
| 35 |
+
the aggregation operation to use during molecule-level predictor
|
| 36 |
+
predictor : Predictor
|
| 37 |
+
the function to use to calculate the final prediction
|
| 38 |
+
batch_norm : bool, default=True
|
| 39 |
+
if `True`, apply batch normalization to the output of the aggregation operation
|
| 40 |
+
metrics : Iterable[Metric] | None, default=None
|
| 41 |
+
the metrics to use to evaluate the model during training and evaluation
|
| 42 |
+
warmup_epochs : int, default=2
|
| 43 |
+
the number of epochs to use for the learning rate warmup
|
| 44 |
+
init_lr : int, default=1e-4
|
| 45 |
+
the initial learning rate
|
| 46 |
+
max_lr : float, default=1e-3
|
| 47 |
+
the maximum learning rate
|
| 48 |
+
final_lr : float, default=1e-4
|
| 49 |
+
the final learning rate
|
| 50 |
+
|
| 51 |
+
Raises
|
| 52 |
+
------
|
| 53 |
+
ValueError
|
| 54 |
+
if the output dimension of the message passing block does not match the input dimension of
|
| 55 |
+
the predictor function
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
def __init__(
|
| 59 |
+
self,
|
| 60 |
+
message_passing: MessagePassing,
|
| 61 |
+
agg: Aggregation,
|
| 62 |
+
predictor: Predictor,
|
| 63 |
+
batch_norm: bool = True,
|
| 64 |
+
metrics: Iterable[Metric] | None = None,
|
| 65 |
+
warmup_epochs: int = 2,
|
| 66 |
+
init_lr: float = 1e-4,
|
| 67 |
+
max_lr: float = 1e-3,
|
| 68 |
+
final_lr: float = 1e-4,
|
| 69 |
+
X_d_transform: ScaleTransform | None = None,
|
| 70 |
+
):
|
| 71 |
+
super().__init__()
|
| 72 |
+
|
| 73 |
+
self.save_hyperparameters(ignore=["message_passing", "agg", "predictor"])
|
| 74 |
+
self.hparams.update(
|
| 75 |
+
{
|
| 76 |
+
"message_passing": message_passing.hparams,
|
| 77 |
+
"agg": agg.hparams,
|
| 78 |
+
"predictor": predictor.hparams,
|
| 79 |
+
}
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
self.message_passing = message_passing
|
| 83 |
+
self.agg = agg
|
| 84 |
+
self.bn = nn.BatchNorm1d(self.message_passing.output_dim) if batch_norm else nn.Identity()
|
| 85 |
+
self.predictor = predictor
|
| 86 |
+
|
| 87 |
+
self.X_d_transform = X_d_transform if X_d_transform is not None else nn.Identity()
|
| 88 |
+
|
| 89 |
+
self.metrics = (
|
| 90 |
+
[*metrics, self.criterion]
|
| 91 |
+
if metrics
|
| 92 |
+
else [self.predictor._T_default_metric(), self.criterion]
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
self.warmup_epochs = warmup_epochs
|
| 96 |
+
self.init_lr = init_lr
|
| 97 |
+
self.max_lr = max_lr
|
| 98 |
+
self.final_lr = final_lr
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def output_dim(self) -> int:
|
| 102 |
+
return self.predictor.output_dim
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def n_tasks(self) -> int:
|
| 106 |
+
return self.predictor.n_tasks
|
| 107 |
+
|
| 108 |
+
@property
|
| 109 |
+
def n_targets(self) -> int:
|
| 110 |
+
return self.predictor.n_targets
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def criterion(self) -> LossFunction:
|
| 114 |
+
return self.predictor.criterion
|
| 115 |
+
|
| 116 |
+
def fingerprint(
|
| 117 |
+
self, bmg: BatchMolGraph, V_d: Tensor | None = None, X_d: Tensor | None = None
|
| 118 |
+
) -> Tensor:
|
| 119 |
+
"""the learned fingerprints for the input molecules"""
|
| 120 |
+
H_v = self.message_passing(bmg, V_d)
|
| 121 |
+
H = self.agg(H_v, bmg.batch)
|
| 122 |
+
H = self.bn(H)
|
| 123 |
+
|
| 124 |
+
return H if X_d is None else torch.cat((H, self.X_d_transform(X_d)), 1)
|
| 125 |
+
|
| 126 |
+
def encoding(
|
| 127 |
+
self, bmg: BatchMolGraph, V_d: Tensor | None = None, X_d: Tensor | None = None, i: int = -1
|
| 128 |
+
) -> Tensor:
|
| 129 |
+
"""Calculate the :attr:`i`-th hidden representation"""
|
| 130 |
+
return self.predictor.encode(self.fingerprint(bmg, V_d, X_d), i)
|
| 131 |
+
|
| 132 |
+
def forward(
|
| 133 |
+
self, bmg: BatchMolGraph, V_d: Tensor | None = None, X_d: Tensor | None = None
|
| 134 |
+
) -> Tensor:
|
| 135 |
+
"""Generate predictions for the input molecules/reactions"""
|
| 136 |
+
return self.predictor(self.fingerprint(bmg, V_d, X_d))
|
| 137 |
+
|
| 138 |
+
def training_step(self, batch: TrainingBatch, batch_idx):
|
| 139 |
+
bmg, V_d, X_d, targets, weights, lt_mask, gt_mask = batch
|
| 140 |
+
|
| 141 |
+
mask = targets.isfinite()
|
| 142 |
+
targets = targets.nan_to_num(nan=0.0)
|
| 143 |
+
|
| 144 |
+
Z = self.fingerprint(bmg, V_d, X_d)
|
| 145 |
+
preds = self.predictor.train_step(Z)
|
| 146 |
+
l = self.criterion(preds, targets, mask, weights, lt_mask, gt_mask)
|
| 147 |
+
|
| 148 |
+
self.log("train_loss", l, prog_bar=True)
|
| 149 |
+
|
| 150 |
+
return l
|
| 151 |
+
|
| 152 |
+
def on_validation_model_eval(self) -> None:
|
| 153 |
+
self.eval()
|
| 154 |
+
self.predictor.output_transform.train()
|
| 155 |
+
|
| 156 |
+
def validation_step(self, batch: TrainingBatch, batch_idx: int = 0):
|
| 157 |
+
losses = self._evaluate_batch(batch)
|
| 158 |
+
metric2loss = {f"val/{m.alias}": l for m, l in zip(self.metrics, losses)}
|
| 159 |
+
|
| 160 |
+
self.log_dict(metric2loss, batch_size=len(batch[0]))
|
| 161 |
+
self.log("val_loss", losses[0], batch_size=len(batch[0]), prog_bar=True)
|
| 162 |
+
|
| 163 |
+
def test_step(self, batch: TrainingBatch, batch_idx: int = 0):
|
| 164 |
+
losses = self._evaluate_batch(batch)
|
| 165 |
+
metric2loss = {f"batch_averaged_test/{m.alias}": l for m, l in zip(self.metrics, losses)}
|
| 166 |
+
|
| 167 |
+
self.log_dict(metric2loss, batch_size=len(batch[0]))
|
| 168 |
+
|
| 169 |
+
def _evaluate_batch(self, batch) -> list[Tensor]:
|
| 170 |
+
bmg, V_d, X_d, targets, _, lt_mask, gt_mask = batch
|
| 171 |
+
|
| 172 |
+
mask = targets.isfinite()
|
| 173 |
+
targets = targets.nan_to_num(nan=0.0)
|
| 174 |
+
preds = self(bmg, V_d, X_d)
|
| 175 |
+
|
| 176 |
+
return [
|
| 177 |
+
metric(preds, targets, mask, None, lt_mask, gt_mask) for metric in self.metrics[:-1]
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
def predict_step(self, batch: TrainingBatch, batch_idx: int, dataloader_idx: int = 0) -> Tensor:
|
| 181 |
+
"""Return the predictions of the input batch
|
| 182 |
+
|
| 183 |
+
Parameters
|
| 184 |
+
----------
|
| 185 |
+
batch : TrainingBatch
|
| 186 |
+
the input batch
|
| 187 |
+
|
| 188 |
+
Returns
|
| 189 |
+
-------
|
| 190 |
+
Tensor
|
| 191 |
+
a tensor of varying shape depending on the task type:
|
| 192 |
+
|
| 193 |
+
* regression/binary classification: ``n x (t * s)``, where ``n`` is the number of input
|
| 194 |
+
molecules/reactions, ``t`` is the number of tasks, and ``s`` is the number of targets
|
| 195 |
+
per task. The final dimension is flattened, so that the targets for each task are
|
| 196 |
+
grouped. I.e., the first ``t`` elements are the first target for each task, the second
|
| 197 |
+
``t`` elements the second target, etc.
|
| 198 |
+
* multiclass classification: ``n x t x c``, where ``c`` is the number of classes
|
| 199 |
+
"""
|
| 200 |
+
bmg, X_vd, X_d, *_ = batch
|
| 201 |
+
|
| 202 |
+
return self(bmg, X_vd, X_d)
|
| 203 |
+
|
| 204 |
+
def configure_optimizers(self):
|
| 205 |
+
opt = optim.Adam(self.parameters(), self.init_lr)
|
| 206 |
+
|
| 207 |
+
lr_sched = NoamLR(
|
| 208 |
+
opt,
|
| 209 |
+
self.warmup_epochs,
|
| 210 |
+
self.trainer.max_epochs,
|
| 211 |
+
self.trainer.estimated_stepping_batches // self.trainer.max_epochs,
|
| 212 |
+
self.init_lr,
|
| 213 |
+
self.max_lr,
|
| 214 |
+
self.final_lr,
|
| 215 |
+
)
|
| 216 |
+
lr_sched_config = {
|
| 217 |
+
"scheduler": lr_sched,
|
| 218 |
+
"interval": "step" if isinstance(lr_sched, NoamLR) else "batch",
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
return {"optimizer": opt, "lr_scheduler": lr_sched_config}
|
| 222 |
+
|
| 223 |
+
@classmethod
|
| 224 |
+
def load_submodules(cls, checkpoint_path, **kwargs):
|
| 225 |
+
hparams = torch.load(checkpoint_path)["hyper_parameters"]
|
| 226 |
+
|
| 227 |
+
kwargs |= {
|
| 228 |
+
key: hparams[key].pop("cls")(**hparams[key])
|
| 229 |
+
for key in ("message_passing", "agg", "predictor")
|
| 230 |
+
if key not in kwargs
|
| 231 |
+
}
|
| 232 |
+
return kwargs
|
| 233 |
+
|
| 234 |
+
@classmethod
|
| 235 |
+
def load_from_checkpoint(
|
| 236 |
+
cls, checkpoint_path, map_location=None, hparams_file=None, strict=True, **kwargs
|
| 237 |
+
) -> MPNN:
|
| 238 |
+
kwargs = cls.load_submodules(checkpoint_path, **kwargs)
|
| 239 |
+
return super().load_from_checkpoint(
|
| 240 |
+
checkpoint_path, map_location, hparams_file, strict, **kwargs
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
@classmethod
|
| 244 |
+
def load_from_file(cls, model_path, map_location=None, strict=True) -> MPNN:
|
| 245 |
+
d = torch.load(model_path, map_location=map_location)
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
hparams = d["hyper_parameters"]
|
| 249 |
+
state_dict = d["state_dict"]
|
| 250 |
+
except KeyError:
|
| 251 |
+
raise KeyError(f"Could not find hyper parameters and/or state dict in {model_path}. ")
|
| 252 |
+
|
| 253 |
+
for key in ["message_passing", "agg", "predictor"]:
|
| 254 |
+
hparam_kwargs = hparams[key]
|
| 255 |
+
hparam_cls = hparam_kwargs.pop("cls")
|
| 256 |
+
hparams[key] = hparam_cls(**hparam_kwargs)
|
| 257 |
+
|
| 258 |
+
model = cls(**hparams)
|
| 259 |
+
model.load_state_dict(state_dict, strict=strict)
|
| 260 |
+
|
| 261 |
+
return model
|
chemprop/models/multi.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Iterable
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from torch import Tensor
|
| 5 |
+
|
| 6 |
+
from chemprop.data import BatchMolGraph
|
| 7 |
+
from chemprop.nn import MulticomponentMessagePassing, Aggregation, Predictor
|
| 8 |
+
from chemprop.models.model import MPNN
|
| 9 |
+
from chemprop.nn.metrics import Metric
|
| 10 |
+
from chemprop.nn.transforms import ScaleTransform
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MulticomponentMPNN(MPNN):
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
message_passing: MulticomponentMessagePassing,
|
| 17 |
+
agg: Aggregation,
|
| 18 |
+
predictor: Predictor,
|
| 19 |
+
batch_norm: bool = True,
|
| 20 |
+
metrics: Iterable[Metric] | None = None,
|
| 21 |
+
warmup_epochs: int = 2,
|
| 22 |
+
init_lr: float = 1e-4,
|
| 23 |
+
max_lr: float = 1e-3,
|
| 24 |
+
final_lr: float = 1e-4,
|
| 25 |
+
X_d_transform: ScaleTransform | None = None,
|
| 26 |
+
):
|
| 27 |
+
super().__init__(
|
| 28 |
+
message_passing,
|
| 29 |
+
agg,
|
| 30 |
+
predictor,
|
| 31 |
+
batch_norm,
|
| 32 |
+
metrics,
|
| 33 |
+
warmup_epochs,
|
| 34 |
+
init_lr,
|
| 35 |
+
max_lr,
|
| 36 |
+
final_lr,
|
| 37 |
+
X_d_transform,
|
| 38 |
+
)
|
| 39 |
+
self.message_passing: MulticomponentMessagePassing
|
| 40 |
+
|
| 41 |
+
def fingerprint(
|
| 42 |
+
self,
|
| 43 |
+
bmgs: Iterable[BatchMolGraph],
|
| 44 |
+
V_ds: Iterable[Tensor | None],
|
| 45 |
+
X_d: Tensor | None = None,
|
| 46 |
+
) -> Tensor:
|
| 47 |
+
H_vs: list[Tensor] = self.message_passing(bmgs, V_ds)
|
| 48 |
+
Hs = [self.agg(H_v, bmg.batch) for H_v, bmg in zip(H_vs, bmgs)]
|
| 49 |
+
H = torch.cat(Hs, 1)
|
| 50 |
+
H = self.bn(H)
|
| 51 |
+
|
| 52 |
+
return H if X_d is None else torch.cat((H, self.X_d_transform(X_d)), 1)
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def load_submodules(cls, checkpoint_path, **kwargs):
|
| 56 |
+
hparams = torch.load(checkpoint_path)["hyper_parameters"]
|
| 57 |
+
|
| 58 |
+
hparams["message_passing"]["blocks"] = [
|
| 59 |
+
block_hparams.pop("cls")(**block_hparams)
|
| 60 |
+
for block_hparams in hparams["message_passing"]["blocks"]
|
| 61 |
+
]
|
| 62 |
+
kwargs |= {
|
| 63 |
+
key: hparams[key].pop("cls")(**hparams[key])
|
| 64 |
+
for key in ("message_passing", "agg", "predictor")
|
| 65 |
+
if key not in kwargs
|
| 66 |
+
}
|
| 67 |
+
return kwargs
|
| 68 |
+
|
| 69 |
+
@classmethod
|
| 70 |
+
def load_from_file(cls, model_path, map_location=None, strict=True) -> MPNN:
|
| 71 |
+
d = torch.load(model_path, map_location=map_location)
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
hparams = d["hyper_parameters"]
|
| 75 |
+
state_dict = d["state_dict"]
|
| 76 |
+
except KeyError:
|
| 77 |
+
raise KeyError(f"Could not find hyper parameters and/or state dict in {model_path}. ")
|
| 78 |
+
|
| 79 |
+
for key in ["message_passing", "agg", "predictor"]:
|
| 80 |
+
hparam_kwargs = hparams[key]
|
| 81 |
+
if key == "message_passing":
|
| 82 |
+
hparam_kwargs["blocks"] = [
|
| 83 |
+
block_hparams.pop("cls")(**block_hparams)
|
| 84 |
+
for block_hparams in hparam_kwargs["blocks"]
|
| 85 |
+
]
|
| 86 |
+
hparam_cls = hparam_kwargs.pop("cls")
|
| 87 |
+
hparams[key] = hparam_cls(**hparam_kwargs)
|
| 88 |
+
|
| 89 |
+
model = cls(**hparams)
|
| 90 |
+
model.load_state_dict(state_dict, strict=strict)
|
| 91 |
+
|
| 92 |
+
return model
|
chemprop/models/utils.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from os import PathLike
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from chemprop.models.model import MPNN
|
| 5 |
+
from chemprop.models.multi import MulticomponentMPNN
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def save_model(path: PathLike, model: MPNN) -> None:
|
| 9 |
+
torch.save({"hyper_parameters": model.hparams, "state_dict": model.state_dict()}, path)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_model(path: PathLike, multicomponent: bool) -> MPNN:
|
| 13 |
+
if multicomponent:
|
| 14 |
+
model = MulticomponentMPNN.load_from_file(path)
|
| 15 |
+
else:
|
| 16 |
+
model = MPNN.load_from_file(path)
|
| 17 |
+
|
| 18 |
+
return model
|
chemprop/nn/__init__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .agg import (
|
| 2 |
+
Aggregation,
|
| 3 |
+
AggregationRegistry,
|
| 4 |
+
MeanAggregation,
|
| 5 |
+
SumAggregation,
|
| 6 |
+
NormAggregation,
|
| 7 |
+
AttentiveAggregation,
|
| 8 |
+
)
|
| 9 |
+
from .loss import (
|
| 10 |
+
LossFunction,
|
| 11 |
+
LossFunctionRegistry,
|
| 12 |
+
MSELoss,
|
| 13 |
+
BoundedMSELoss,
|
| 14 |
+
MVELoss,
|
| 15 |
+
EvidentialLoss,
|
| 16 |
+
BCELoss,
|
| 17 |
+
CrossEntropyLoss,
|
| 18 |
+
MccMixin,
|
| 19 |
+
BinaryMCCLoss,
|
| 20 |
+
MulticlassMCCLoss,
|
| 21 |
+
DirichletMixin,
|
| 22 |
+
BinaryDirichletLoss,
|
| 23 |
+
MulticlassDirichletLoss,
|
| 24 |
+
SIDLoss,
|
| 25 |
+
WassersteinLoss,
|
| 26 |
+
)
|
| 27 |
+
from .metrics import (
|
| 28 |
+
Metric,
|
| 29 |
+
MetricRegistry,
|
| 30 |
+
ThresholdedMixin,
|
| 31 |
+
MAEMetric,
|
| 32 |
+
MSEMetric,
|
| 33 |
+
RMSEMetric,
|
| 34 |
+
BoundedMixin,
|
| 35 |
+
BoundedMAEMetric,
|
| 36 |
+
BoundedMSEMetric,
|
| 37 |
+
BoundedRMSEMetric,
|
| 38 |
+
R2Metric,
|
| 39 |
+
BinaryAUROCMetric,
|
| 40 |
+
BinaryAUPRCMetric,
|
| 41 |
+
BinaryAccuracyMetric,
|
| 42 |
+
BinaryF1Metric,
|
| 43 |
+
BCEMetric,
|
| 44 |
+
CrossEntropyMetric,
|
| 45 |
+
BinaryMCCMetric,
|
| 46 |
+
MulticlassMCCMetric,
|
| 47 |
+
SIDMetric,
|
| 48 |
+
WassersteinMetric,
|
| 49 |
+
)
|
| 50 |
+
from .message_passing import (
|
| 51 |
+
MessagePassing,
|
| 52 |
+
AtomMessagePassing,
|
| 53 |
+
BondMessagePassing,
|
| 54 |
+
MulticomponentMessagePassing,
|
| 55 |
+
)
|
| 56 |
+
from .predictors import (
|
| 57 |
+
Predictor,
|
| 58 |
+
PredictorRegistry,
|
| 59 |
+
RegressionFFN,
|
| 60 |
+
MveFFN,
|
| 61 |
+
EvidentialFFN,
|
| 62 |
+
BinaryClassificationFFNBase,
|
| 63 |
+
BinaryClassificationFFN,
|
| 64 |
+
BinaryDirichletFFN,
|
| 65 |
+
MulticlassClassificationFFN,
|
| 66 |
+
MulticlassDirichletFFN,
|
| 67 |
+
SpectralFFN,
|
| 68 |
+
)
|
| 69 |
+
from .utils import Activation
|
| 70 |
+
from .transforms import UnscaleTransform
|
| 71 |
+
|
| 72 |
+
__all__ = [
|
| 73 |
+
"Aggregation",
|
| 74 |
+
"AggregationRegistry",
|
| 75 |
+
"MeanAggregation",
|
| 76 |
+
"SumAggregation",
|
| 77 |
+
"NormAggregation",
|
| 78 |
+
"AttentiveAggregation",
|
| 79 |
+
"LossFunction",
|
| 80 |
+
"LossFunctionRegistry",
|
| 81 |
+
"MSELoss",
|
| 82 |
+
"BoundedMSELoss",
|
| 83 |
+
"MVELoss",
|
| 84 |
+
"EvidentialLoss",
|
| 85 |
+
"BCELoss",
|
| 86 |
+
"CrossEntropyLoss",
|
| 87 |
+
"MccMixin",
|
| 88 |
+
"BinaryMCCLoss",
|
| 89 |
+
"MulticlassMCCLoss",
|
| 90 |
+
"DirichletMixin",
|
| 91 |
+
"BinaryDirichletLoss",
|
| 92 |
+
"MulticlassDirichletLoss",
|
| 93 |
+
"SIDLoss",
|
| 94 |
+
"WassersteinLoss",
|
| 95 |
+
"Metric",
|
| 96 |
+
"MetricRegistry",
|
| 97 |
+
"ThresholdedMixin",
|
| 98 |
+
"MAEMetric",
|
| 99 |
+
"MSEMetric",
|
| 100 |
+
"RMSEMetric",
|
| 101 |
+
"BoundedMixin",
|
| 102 |
+
"BoundedMAEMetric",
|
| 103 |
+
"BoundedMSEMetric",
|
| 104 |
+
"BoundedRMSEMetric",
|
| 105 |
+
"R2Metric",
|
| 106 |
+
"BinaryAUROCMetric",
|
| 107 |
+
"BinaryAUPRCMetric",
|
| 108 |
+
"BinaryAccuracyMetric",
|
| 109 |
+
"BinaryF1Metric",
|
| 110 |
+
"BCEMetric",
|
| 111 |
+
"CrossEntropyMetric",
|
| 112 |
+
"BinaryMCCMetric",
|
| 113 |
+
"MulticlassMCCMetric",
|
| 114 |
+
"SIDMetric",
|
| 115 |
+
"WassersteinMetric",
|
| 116 |
+
"MessagePassing",
|
| 117 |
+
"AtomMessagePassing",
|
| 118 |
+
"BondMessagePassing",
|
| 119 |
+
"MulticomponentMessagePassing",
|
| 120 |
+
"Predictor",
|
| 121 |
+
"PredictorRegistry",
|
| 122 |
+
"RegressionFFN",
|
| 123 |
+
"MveFFN",
|
| 124 |
+
"EvidentialFFN",
|
| 125 |
+
"BinaryClassificationFFNBase",
|
| 126 |
+
"BinaryClassificationFFN",
|
| 127 |
+
"BinaryDirichletFFN",
|
| 128 |
+
"MulticlassClassificationFFN",
|
| 129 |
+
"MulticlassDirichletFFN",
|
| 130 |
+
"SpectralFFN",
|
| 131 |
+
"Activation",
|
| 132 |
+
"UnscaleTransform",
|
| 133 |
+
]
|
chemprop/nn/agg.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
import torch
|
| 3 |
+
from torch import Tensor, nn
|
| 4 |
+
|
| 5 |
+
from chemprop.utils import ClassRegistry
|
| 6 |
+
from chemprop.nn.hparams import HasHParams
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"Aggregation",
|
| 11 |
+
"AggregationRegistry",
|
| 12 |
+
"MeanAggregation",
|
| 13 |
+
"SumAggregation",
|
| 14 |
+
"NormAggregation",
|
| 15 |
+
"AttentiveAggregation",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Aggregation(nn.Module, HasHParams):
|
| 20 |
+
"""An :class:`Aggregation` aggregates the node-level representations of a batch of graphs into
|
| 21 |
+
a batch of graph-level representations
|
| 22 |
+
|
| 23 |
+
.. note::
|
| 24 |
+
this class is abstract and cannot be instantiated.
|
| 25 |
+
|
| 26 |
+
See also
|
| 27 |
+
--------
|
| 28 |
+
:class:`~chemprop.v2.models.modules.agg.MeanAggregation`
|
| 29 |
+
:class:`~chemprop.v2.models.modules.agg.SumAggregation`
|
| 30 |
+
:class:`~chemprop.v2.models.modules.agg.NormAggregation`
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, dim: int = 0, *args, **kwargs):
|
| 34 |
+
super().__init__()
|
| 35 |
+
|
| 36 |
+
self.dim = dim
|
| 37 |
+
self.hparams = {"dim": dim, "cls": self.__class__}
|
| 38 |
+
|
| 39 |
+
@abstractmethod
|
| 40 |
+
def forward(self, H: Tensor, batch: Tensor) -> Tensor:
|
| 41 |
+
"""Aggregate the graph-level representations of a batch of graphs into their respective
|
| 42 |
+
global representations
|
| 43 |
+
|
| 44 |
+
NOTE: it is possible for a graph to have 0 nodes. In this case, the representation will be
|
| 45 |
+
a zero vector of length `d` in the final output.
|
| 46 |
+
|
| 47 |
+
Parameters
|
| 48 |
+
----------
|
| 49 |
+
H : Tensor
|
| 50 |
+
a tensor of shape ``V x d`` containing the batched node-level representations of ``b``
|
| 51 |
+
graphs
|
| 52 |
+
batch : Tensor
|
| 53 |
+
a tensor of shape ``V`` containing the index of the graph a given vertex corresponds to
|
| 54 |
+
|
| 55 |
+
Returns
|
| 56 |
+
-------
|
| 57 |
+
Tensor
|
| 58 |
+
a tensor of shape ``b x d`` containing the graph-level representations
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
AggregationRegistry = ClassRegistry[Aggregation]()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@AggregationRegistry.register("mean")
|
| 66 |
+
class MeanAggregation(Aggregation):
|
| 67 |
+
r"""Average the graph-level representation:
|
| 68 |
+
|
| 69 |
+
.. math::
|
| 70 |
+
\mathbf h = \frac{1}{|V|} \sum_{v \in V} \mathbf h_v
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def forward(self, H: Tensor, batch: Tensor) -> Tensor:
|
| 74 |
+
index_torch = batch.unsqueeze(1).repeat(1, H.shape[1])
|
| 75 |
+
dim_size = batch.max().int() + 1
|
| 76 |
+
return torch.zeros(dim_size, H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 77 |
+
self.dim, index_torch, H, reduce="mean", include_self=False
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@AggregationRegistry.register("sum")
|
| 82 |
+
class SumAggregation(Aggregation):
|
| 83 |
+
r"""Sum the graph-level representation:
|
| 84 |
+
|
| 85 |
+
.. math::
|
| 86 |
+
\mathbf h = \sum_{v \in V} \mathbf h_v
|
| 87 |
+
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
def forward(self, H: Tensor, batch: Tensor) -> Tensor:
|
| 91 |
+
index_torch = batch.unsqueeze(1).repeat(1, H.shape[1])
|
| 92 |
+
dim_size = batch.max().int() + 1
|
| 93 |
+
return torch.zeros(dim_size, H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 94 |
+
self.dim, index_torch, H, reduce="sum", include_self=False
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@AggregationRegistry.register("norm")
|
| 99 |
+
class NormAggregation(SumAggregation):
|
| 100 |
+
r"""Sum the graph-level representation and divide by a normalization constant:
|
| 101 |
+
|
| 102 |
+
.. math::
|
| 103 |
+
\mathbf h = \frac{1}{c} \sum_{v \in V} \mathbf h_v
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
def __init__(self, dim: int = 0, *args, norm: float = 100.0, **kwargs):
|
| 107 |
+
super().__init__(dim, **kwargs)
|
| 108 |
+
|
| 109 |
+
self.norm = norm
|
| 110 |
+
self.hparams["norm"] = norm
|
| 111 |
+
|
| 112 |
+
def forward(self, H: Tensor, batch: Tensor) -> Tensor:
|
| 113 |
+
return super().forward(H, batch) / self.norm
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class AttentiveAggregation(Aggregation):
|
| 117 |
+
def __init__(self, dim: int = 0, *args, output_size: int, **kwargs):
|
| 118 |
+
super().__init__(dim, *args, **kwargs)
|
| 119 |
+
|
| 120 |
+
self.W = nn.Linear(output_size, 1)
|
| 121 |
+
|
| 122 |
+
def forward(self, H: Tensor, batch: Tensor) -> Tensor:
|
| 123 |
+
dim_size = batch.max().int() + 1
|
| 124 |
+
attention_logits = self.W(H).exp()
|
| 125 |
+
Z = torch.zeros(dim_size, 1, dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 126 |
+
self.dim, batch.unsqueeze(1), attention_logits, reduce="sum", include_self=False
|
| 127 |
+
)
|
| 128 |
+
alphas = attention_logits / Z[batch]
|
| 129 |
+
index_torch = batch.unsqueeze(1).repeat(1, H.shape[1])
|
| 130 |
+
return torch.zeros(dim_size, H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 131 |
+
self.dim, index_torch, alphas * H, reduce="sum", include_self=False
|
| 132 |
+
)
|
chemprop/nn/ffn.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
|
| 3 |
+
from torch import nn, Tensor
|
| 4 |
+
|
| 5 |
+
from chemprop.nn.utils import get_activation_function
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FFN(nn.Module):
|
| 9 |
+
r"""A :class:`FFN` is a differentiable function
|
| 10 |
+
:math:`f_\theta : \mathbb R^i \mapsto \mathbb R^o`"""
|
| 11 |
+
|
| 12 |
+
input_dim: int
|
| 13 |
+
output_dim: int
|
| 14 |
+
|
| 15 |
+
@abstractmethod
|
| 16 |
+
def forward(self, X: Tensor) -> Tensor:
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class MLP(nn.Sequential, FFN):
|
| 21 |
+
r"""An :class:`MLP` is an FFN that implements the following function:
|
| 22 |
+
|
| 23 |
+
.. math::
|
| 24 |
+
\mathbf h_0 &= \mathbf W_0 \mathbf x \,+ \mathbf b_{0} \\
|
| 25 |
+
\mathbf h_l &= \mathbf W_l \left( \mathtt{dropout} \left( \sigma ( \,\mathbf h_{l-1}\, ) \right) \right) + \mathbf b_l\\
|
| 26 |
+
|
| 27 |
+
where :math:`\mathbf x` is the input tensor, :math:`\mathbf W_l` and :math:`\mathbf b_l`
|
| 28 |
+
are the learned weight matrix and bias, respectively, of the :math:`l`-th layer,
|
| 29 |
+
:math:`\mathbf h_l` is the hidden representation after layer :math:`l`, and :math:`\sigma`
|
| 30 |
+
is the activation function.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def build(
|
| 35 |
+
cls,
|
| 36 |
+
input_dim: int,
|
| 37 |
+
output_dim: int,
|
| 38 |
+
hidden_dim: int = 300,
|
| 39 |
+
n_layers: int = 1,
|
| 40 |
+
dropout: float = 0.0,
|
| 41 |
+
activation: str = "relu",
|
| 42 |
+
):
|
| 43 |
+
dropout = nn.Dropout(dropout)
|
| 44 |
+
act = get_activation_function(activation)
|
| 45 |
+
dims = [input_dim] + [hidden_dim] * n_layers + [output_dim]
|
| 46 |
+
blocks = [nn.Sequential(nn.Linear(dims[0], dims[1]))]
|
| 47 |
+
if len(dims) > 2:
|
| 48 |
+
blocks.extend(
|
| 49 |
+
[
|
| 50 |
+
nn.Sequential(act, dropout, nn.Linear(d1, d2))
|
| 51 |
+
for d1, d2 in zip(dims[1:-1], dims[2:])
|
| 52 |
+
]
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return cls(*blocks)
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def input_dim(self) -> int:
|
| 59 |
+
return self[0][-1].in_features
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def output_dim(self) -> int:
|
| 63 |
+
return self[-1][-1].out_features
|
chemprop/nn/hparams.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Protocol, Type, TypedDict
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class HParamsDict(TypedDict):
|
| 5 |
+
"""A dictionary containing a module's class and it's hyperparameters
|
| 6 |
+
|
| 7 |
+
Using this type should essentially allow for initializing a module via::
|
| 8 |
+
|
| 9 |
+
module = hparams.pop('cls')(**hparams)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
cls: Type
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class HasHParams(Protocol):
|
| 16 |
+
""":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.
|
| 17 |
+
|
| 18 |
+
That is, any object which implements :class:`HasHParams` should be able to be initialized via::
|
| 19 |
+
|
| 20 |
+
class Foo(HasHParams):
|
| 21 |
+
def __init__(self, *args, **kwargs):
|
| 22 |
+
...
|
| 23 |
+
|
| 24 |
+
foo1 = Foo(...)
|
| 25 |
+
foo1_cls = foo1.hparams['cls']
|
| 26 |
+
foo1_kwargs = {k: v for k, v in foo1.hparams.items() if k != "cls"}
|
| 27 |
+
foo2 = foo1_cls(**foo1_kwargs)
|
| 28 |
+
# code to compare foo1 and foo2 goes here and they should be equal
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
hparams: HParamsDict
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def from_hparams(hparams: HParamsDict):
|
| 35 |
+
cls = hparams["cls"]
|
| 36 |
+
kwargs = {k: v for k, v in hparams.items() if k != "cls"}
|
| 37 |
+
|
| 38 |
+
return cls(**kwargs)
|
chemprop/nn/loss.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
import torch
|
| 3 |
+
from torch import Tensor, nn
|
| 4 |
+
from torch.nn import functional as F
|
| 5 |
+
from numpy.typing import ArrayLike
|
| 6 |
+
|
| 7 |
+
from chemprop.utils import ClassRegistry
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"LossFunction",
|
| 12 |
+
"LossFunctionRegistry",
|
| 13 |
+
"MSELoss",
|
| 14 |
+
"BoundedMSELoss",
|
| 15 |
+
"MVELoss",
|
| 16 |
+
"EvidentialLoss",
|
| 17 |
+
"BCELoss",
|
| 18 |
+
"CrossEntropyLoss",
|
| 19 |
+
"MccMixin",
|
| 20 |
+
"BinaryMCCLoss",
|
| 21 |
+
"MulticlassMCCLoss",
|
| 22 |
+
"DirichletMixin",
|
| 23 |
+
"BinaryDirichletLoss",
|
| 24 |
+
"MulticlassDirichletLoss",
|
| 25 |
+
"SIDLoss",
|
| 26 |
+
"WassersteinLoss",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class LossFunction(nn.Module):
|
| 31 |
+
def __init__(self, task_weights: ArrayLike = 1.0):
|
| 32 |
+
"""
|
| 33 |
+
Parameters
|
| 34 |
+
----------
|
| 35 |
+
task_weights : ArrayLike, default=1.0
|
| 36 |
+
the per-task weights of shape `t` or `1 x t`. Defaults to all tasks having a weight of 1.
|
| 37 |
+
"""
|
| 38 |
+
super().__init__()
|
| 39 |
+
task_weights = torch.as_tensor(task_weights, dtype=torch.float).view(1, -1)
|
| 40 |
+
self.register_buffer("task_weights", task_weights)
|
| 41 |
+
|
| 42 |
+
def forward(
|
| 43 |
+
self,
|
| 44 |
+
preds: Tensor,
|
| 45 |
+
targets: Tensor,
|
| 46 |
+
mask: Tensor,
|
| 47 |
+
weights: Tensor,
|
| 48 |
+
lt_mask: Tensor,
|
| 49 |
+
gt_mask: Tensor,
|
| 50 |
+
):
|
| 51 |
+
"""Calculate the mean loss function value given predicted and target values
|
| 52 |
+
|
| 53 |
+
Parameters
|
| 54 |
+
----------
|
| 55 |
+
preds : Tensor
|
| 56 |
+
a tensor of shape `b x (t * s)` (regression), `b x t` (binary classification), or
|
| 57 |
+
`b x t x c` (multiclass classification) containing the predictions, where `b` is the
|
| 58 |
+
batch size, `t` is the number of tasks to predict, `s` is the number of
|
| 59 |
+
targets to predict for each task, and `c` is the number of classes.
|
| 60 |
+
targets : Tensor
|
| 61 |
+
a float tensor of shape `b x t` containing the target values
|
| 62 |
+
mask : Tensor
|
| 63 |
+
a boolean tensor of shape `b x t` indicating whether the given prediction should be
|
| 64 |
+
included in the loss calculation
|
| 65 |
+
weights : Tensor
|
| 66 |
+
a tensor of shape `b` or `b x 1` containing the per-sample weight
|
| 67 |
+
lt_mask: Tensor
|
| 68 |
+
gt_mask: Tensor
|
| 69 |
+
|
| 70 |
+
Returns
|
| 71 |
+
-------
|
| 72 |
+
Tensor
|
| 73 |
+
a scalar containing the fully reduced loss
|
| 74 |
+
"""
|
| 75 |
+
L = self._calc_unreduced_loss(preds, targets, mask, weights, lt_mask, gt_mask)
|
| 76 |
+
L = L * weights.view(-1, 1) * self.task_weights.view(1, -1) * mask
|
| 77 |
+
|
| 78 |
+
return L.sum() / mask.sum()
|
| 79 |
+
|
| 80 |
+
@abstractmethod
|
| 81 |
+
def _calc_unreduced_loss(self, preds, targets, mask, weights, lt_mask, gt_mask) -> Tensor:
|
| 82 |
+
"""Calculate a tensor of shape `b x t` containing the unreduced loss values."""
|
| 83 |
+
|
| 84 |
+
def extra_repr(self) -> str:
|
| 85 |
+
return f"task_weights={self.task_weights.tolist()}"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
LossFunctionRegistry = ClassRegistry[LossFunction]()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@LossFunctionRegistry.register("mse")
|
| 92 |
+
class MSELoss(LossFunction):
|
| 93 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor:
|
| 94 |
+
return F.mse_loss(preds, targets, reduction="none")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@LossFunctionRegistry.register("bounded-mse")
|
| 98 |
+
class BoundedMSELoss(MSELoss):
|
| 99 |
+
def _calc_unreduced_loss(
|
| 100 |
+
self, preds: Tensor, targets: Tensor, mask, weights, lt_mask: Tensor, gt_mask: Tensor
|
| 101 |
+
) -> Tensor:
|
| 102 |
+
preds = torch.where((preds < targets) & lt_mask, targets, preds)
|
| 103 |
+
preds = torch.where((preds > targets) & gt_mask, targets, preds)
|
| 104 |
+
|
| 105 |
+
return super()._calc_unreduced_loss(preds, targets)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@LossFunctionRegistry.register("mve")
|
| 109 |
+
class MVELoss(LossFunction):
|
| 110 |
+
"""Calculate the loss using Eq. 9 from [nix1994]_
|
| 111 |
+
|
| 112 |
+
References
|
| 113 |
+
----------
|
| 114 |
+
.. [nix1994] Nix, D. A.; Weigend, A. S. "Estimating the mean and variance of the target
|
| 115 |
+
probability distribution." Proceedings of 1994 IEEE International Conference on Neural
|
| 116 |
+
Networks, 1994 https://doi.org/10.1109/icnn.1994.374138
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor:
|
| 120 |
+
mean, var = torch.chunk(preds, 2, 1)
|
| 121 |
+
|
| 122 |
+
L_sos = (mean - targets) ** 2 / (2 * var)
|
| 123 |
+
L_kl = (2 * torch.pi * var).log() / 2
|
| 124 |
+
|
| 125 |
+
return L_sos + L_kl
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@LossFunctionRegistry.register("evidential")
|
| 129 |
+
class EvidentialLoss(LossFunction):
|
| 130 |
+
"""Calculate the loss using Eqs. 8, 9, and 10 from [amini2020]_
|
| 131 |
+
|
| 132 |
+
References
|
| 133 |
+
----------
|
| 134 |
+
.. [amini2020] Amini, A; Schwarting, W.; Soleimany, A.; Rus, D.;
|
| 135 |
+
"Deep Evidential Regression" Advances in Neural Information Processing Systems;2020; Vol.33.
|
| 136 |
+
https://proceedings.neurips.cc/paper_files/paper/2020/file/aab085461de182608ee9f607f3f7d18f-Paper.pdf
|
| 137 |
+
.. [soleimany2021] Soleimany, A.P.; Amini, A.; Goldman, S.; Rus, D.; Bhatia, S.N.; Coley, C.W.;
|
| 138 |
+
"Evidential Deep Learning for Guided Molecular Property Prediction and Discovery." ACS
|
| 139 |
+
Cent. Sci. 2021, 7, 8, 1356-1367. https://doi.org/10.1021/acscentsci.1c00546
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
def __init__(self, task_weights: Tensor | None = None, v_kl: float = 0.2, eps: float = 1e-8):
|
| 143 |
+
super().__init__(task_weights)
|
| 144 |
+
self.v_kl = v_kl
|
| 145 |
+
self.eps = eps
|
| 146 |
+
|
| 147 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor:
|
| 148 |
+
mean, v, alpha, beta = torch.chunk(preds, 4, 1)
|
| 149 |
+
|
| 150 |
+
residuals = targets - mean
|
| 151 |
+
twoBlambda = 2 * beta * (1 + v)
|
| 152 |
+
|
| 153 |
+
L_nll = (
|
| 154 |
+
0.5 * (torch.pi / v).log()
|
| 155 |
+
- alpha * twoBlambda.log()
|
| 156 |
+
+ (alpha + 0.5) * torch.log(v * residuals**2 + twoBlambda)
|
| 157 |
+
+ torch.lgamma(alpha)
|
| 158 |
+
- torch.lgamma(alpha + 0.5)
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
L_reg = (2 * v + alpha) * residuals.abs()
|
| 162 |
+
|
| 163 |
+
return L_nll + self.v_kl * (L_reg - self.eps)
|
| 164 |
+
|
| 165 |
+
def extra_repr(self) -> str:
|
| 166 |
+
parent_repr = super().extra_repr()
|
| 167 |
+
return parent_repr + f", v_kl={self.v_kl}, eps={self.eps}"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@LossFunctionRegistry.register("bce")
|
| 171 |
+
class BCELoss(LossFunction):
|
| 172 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor:
|
| 173 |
+
return F.binary_cross_entropy_with_logits(preds, targets, reduction="none")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@LossFunctionRegistry.register("ce")
|
| 177 |
+
class CrossEntropyLoss(LossFunction):
|
| 178 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor:
|
| 179 |
+
preds = preds.transpose(1, 2)
|
| 180 |
+
targets = targets.long()
|
| 181 |
+
|
| 182 |
+
return F.cross_entropy(preds, targets, reduction="none")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class MccMixin:
|
| 186 |
+
"""Calculate a soft Matthews correlation coefficient ([mccWiki]_) loss for multiclass
|
| 187 |
+
classification based on the implementataion of [mccSklearn]_
|
| 188 |
+
|
| 189 |
+
References
|
| 190 |
+
----------
|
| 191 |
+
.. [mccWiki] https://en.wikipedia.org/wiki/Phi_coefficient#Multiclass_case
|
| 192 |
+
.. [mccSklearn] https://scikit-learn.org/stable/modules/generated/sklearn.metrics.matthews_corrcoef.html
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
def __call__(self, preds: Tensor, targets: Tensor, mask: Tensor, weights: Tensor, *args):
|
| 196 |
+
if not (0 <= preds.min() and preds.max() <= 1): # assume logits
|
| 197 |
+
preds = preds.softmax(2)
|
| 198 |
+
|
| 199 |
+
L = self._calc_unreduced_loss(preds, targets.long(), mask, weights, *args)
|
| 200 |
+
L = L * self.task_weights
|
| 201 |
+
|
| 202 |
+
return L.mean()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@LossFunctionRegistry.register("binary-mcc")
|
| 206 |
+
class BinaryMCCLoss(LossFunction, MccMixin):
|
| 207 |
+
def _calc_unreduced_loss(self, preds, targets, mask, weights, *args) -> Tensor:
|
| 208 |
+
TP = (targets * preds * weights * mask).sum(0, keepdim=True)
|
| 209 |
+
FP = ((1 - targets) * preds * weights * mask).sum(0, keepdim=True)
|
| 210 |
+
TN = ((1 - targets) * (1 - preds) * weights * mask).sum(0, keepdim=True)
|
| 211 |
+
FN = (targets * (1 - preds) * weights * mask).sum(0, keepdim=True)
|
| 212 |
+
|
| 213 |
+
MCC = (TP * TN - FP * FN) / ((TP + FP) * (TP + FN) * (TN + FP) * (TN + FN)).sqrt()
|
| 214 |
+
|
| 215 |
+
return 1 - MCC
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@LossFunctionRegistry.register("multiclass-mcc")
|
| 219 |
+
class MulticlassMCCLoss(LossFunction, MccMixin):
|
| 220 |
+
def _calc_unreduced_loss(self, preds, targets, mask, weights, *args) -> Tensor:
|
| 221 |
+
device = preds.device
|
| 222 |
+
|
| 223 |
+
C = preds.shape[2]
|
| 224 |
+
bin_targets = torch.eye(C, device=device)[targets]
|
| 225 |
+
bin_preds = torch.eye(C, device=device)[preds.argmax(-1)]
|
| 226 |
+
masked_data_weights = weights.unsqueeze(2) * mask.unsqueeze(2)
|
| 227 |
+
|
| 228 |
+
p = (bin_preds * masked_data_weights).sum(0)
|
| 229 |
+
t = (bin_targets * masked_data_weights).sum(0)
|
| 230 |
+
c = (bin_preds * bin_targets * masked_data_weights).sum()
|
| 231 |
+
s = (preds * masked_data_weights).sum()
|
| 232 |
+
s2 = s.square()
|
| 233 |
+
|
| 234 |
+
# the `einsum` calls amount to calculating the batched dot product
|
| 235 |
+
cov_ytyp = c * s - torch.einsum("ij,ij->i", p, t).sum()
|
| 236 |
+
cov_ypyp = s2 - torch.einsum("ij,ij->i", p, p).sum()
|
| 237 |
+
cov_ytyt = s2 - torch.einsum("ij,ij->i", t, t).sum()
|
| 238 |
+
|
| 239 |
+
x = cov_ypyp * cov_ytyt
|
| 240 |
+
MCC = torch.tensor(0.0, device=device) if x == 0 else cov_ytyp / x.sqrt()
|
| 241 |
+
|
| 242 |
+
return 1 - MCC
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
class DirichletMixin:
|
| 246 |
+
"""Uses the loss function from [sensoy2018]_ based on the implementation at [sensoyGithub]_
|
| 247 |
+
|
| 248 |
+
References
|
| 249 |
+
----------
|
| 250 |
+
.. [sensoy2018] Sensoy, M.; Kaplan, L.; Kandemir, M. "Evidential deep learning to quantify
|
| 251 |
+
classification uncertainty." NeurIPS, 2018, 31. https://doi.org/10.48550/arXiv.1806.01768
|
| 252 |
+
.. [sensoyGithub] https://muratsensoy.github.io/uncertainty.html#Define-the-loss-function
|
| 253 |
+
"""
|
| 254 |
+
|
| 255 |
+
def __init__(self, task_weights: Tensor | None = None, v_kl: float = 0.2):
|
| 256 |
+
super().__init__(task_weights)
|
| 257 |
+
self.v_kl = v_kl
|
| 258 |
+
|
| 259 |
+
def _calc_unreduced_loss(self, preds, targets, *args) -> Tensor:
|
| 260 |
+
S = preds.sum(-1, keepdim=True)
|
| 261 |
+
p = preds / S
|
| 262 |
+
|
| 263 |
+
A = (targets - p).square().sum(-1, keepdim=True)
|
| 264 |
+
B = ((p * (1 - p)) / (S + 1)).sum(-1, keepdim=True)
|
| 265 |
+
|
| 266 |
+
L_mse = A + B
|
| 267 |
+
|
| 268 |
+
alpha = targets + (1 - targets) * preds
|
| 269 |
+
beta = torch.ones_like(alpha)
|
| 270 |
+
S_alpha = alpha.sum(-1, keepdim=True)
|
| 271 |
+
S_beta = beta.sum(-1, keepdim=True)
|
| 272 |
+
|
| 273 |
+
ln_alpha = S_alpha.lgamma() - alpha.lgamma().sum(-1, keepdim=True)
|
| 274 |
+
ln_beta = beta.lgamma().sum(-1, keepdim=True) - S_beta.lgamma()
|
| 275 |
+
|
| 276 |
+
dg0 = torch.digamma(alpha)
|
| 277 |
+
dg1 = torch.digamma(S_alpha)
|
| 278 |
+
|
| 279 |
+
L_kl = ln_alpha + ln_beta + torch.sum((alpha - beta) * (dg0 - dg1), -1, keepdim=True)
|
| 280 |
+
|
| 281 |
+
return (L_mse + self.v_kl * L_kl).mean(-1)
|
| 282 |
+
|
| 283 |
+
def extra_repr(self) -> str:
|
| 284 |
+
return f"v_kl={self.v_kl}"
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@LossFunctionRegistry.register("binary-dirichlet")
|
| 288 |
+
class BinaryDirichletLoss(DirichletMixin, LossFunction):
|
| 289 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, *args) -> Tensor:
|
| 290 |
+
N_CLASSES = 2
|
| 291 |
+
n_tasks = targets.shape[1]
|
| 292 |
+
preds = preds.reshape(len(preds), n_tasks, N_CLASSES)
|
| 293 |
+
y_one_hot = torch.eye(N_CLASSES, device=preds.device)[targets.long()]
|
| 294 |
+
|
| 295 |
+
return super()._calc_unreduced_loss(preds, y_one_hot, *args)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@LossFunctionRegistry.register("multiclass-dirichlet")
|
| 299 |
+
class MulticlassDirichletLoss(DirichletMixin, LossFunction):
|
| 300 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, mask: Tensor, *args) -> Tensor:
|
| 301 |
+
y_one_hot = torch.eye(preds.shape[2], device=preds.device)[targets.long()]
|
| 302 |
+
|
| 303 |
+
return super()._calc_unreduced_loss(preds, y_one_hot, mask)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@LossFunctionRegistry.register("sid")
|
| 307 |
+
class SIDLoss(LossFunction):
|
| 308 |
+
def __init__(self, task_weights: Tensor | None = None, threshold: float | None = None):
|
| 309 |
+
super().__init__(task_weights)
|
| 310 |
+
|
| 311 |
+
self.threshold = threshold
|
| 312 |
+
|
| 313 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, mask: Tensor, *args) -> Tensor:
|
| 314 |
+
if self.threshold is not None:
|
| 315 |
+
preds = preds.clamp(min=self.threshold)
|
| 316 |
+
|
| 317 |
+
preds_norm = preds / (preds * mask).sum(1, keepdim=True)
|
| 318 |
+
|
| 319 |
+
targets = targets.masked_fill(~mask, 1)
|
| 320 |
+
preds_norm = preds_norm.masked_fill(~mask, 1)
|
| 321 |
+
|
| 322 |
+
return (preds_norm / targets).log() * preds_norm + (targets / preds_norm).log() * targets
|
| 323 |
+
|
| 324 |
+
def extra_repr(self) -> str:
|
| 325 |
+
return f"threshold={self.threshold}"
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@LossFunctionRegistry.register(["earthmovers", "wasserstein"])
|
| 329 |
+
class WassersteinLoss(LossFunction):
|
| 330 |
+
def __init__(self, task_weights: Tensor | None = None, threshold: float | None = None):
|
| 331 |
+
super().__init__(task_weights)
|
| 332 |
+
|
| 333 |
+
self.threshold = threshold
|
| 334 |
+
|
| 335 |
+
def _calc_unreduced_loss(self, preds: Tensor, targets: Tensor, mask: Tensor, *args) -> Tensor:
|
| 336 |
+
if self.threshold is not None:
|
| 337 |
+
preds = preds.clamp(min=self.threshold)
|
| 338 |
+
|
| 339 |
+
preds_norm = preds / (preds * mask).sum(1, keepdim=True)
|
| 340 |
+
|
| 341 |
+
return (targets.cumsum(1) - preds_norm.cumsum(1)).abs()
|
| 342 |
+
|
| 343 |
+
def extra_repr(self) -> str:
|
| 344 |
+
return f"threshold={self.threshold}"
|
chemprop/nn/message_passing/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .proto import MessagePassing
|
| 2 |
+
from .base import AtomMessagePassing, BondMessagePassing
|
| 3 |
+
from .multi import MulticomponentMessagePassing
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"MessagePassing",
|
| 7 |
+
"AtomMessagePassing",
|
| 8 |
+
"BondMessagePassing",
|
| 9 |
+
"MulticomponentMessagePassing",
|
| 10 |
+
]
|
chemprop/nn/message_passing/base.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
|
| 3 |
+
from lightning.pytorch.core.mixins import HyperparametersMixin
|
| 4 |
+
import torch
|
| 5 |
+
from torch import Tensor, nn
|
| 6 |
+
|
| 7 |
+
from chemprop.conf import DEFAULT_ATOM_FDIM, DEFAULT_BOND_FDIM, DEFAULT_HIDDEN_DIM
|
| 8 |
+
from chemprop.exceptions import InvalidShapeError
|
| 9 |
+
from chemprop.data import BatchMolGraph
|
| 10 |
+
from chemprop.nn.utils import Activation, get_activation_function
|
| 11 |
+
from chemprop.nn.message_passing.proto import MessagePassing
|
| 12 |
+
from chemprop.nn.transforms import ScaleTransform, GraphTransform
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _MessagePassingBase(MessagePassing, HyperparametersMixin):
|
| 16 |
+
"""The base message-passing block for atom- and bond-based message-passing schemes
|
| 17 |
+
|
| 18 |
+
NOTE: this class is an abstract base class and cannot be instantiated
|
| 19 |
+
|
| 20 |
+
Parameters
|
| 21 |
+
----------
|
| 22 |
+
d_v : int, default=DEFAULT_ATOM_FDIM
|
| 23 |
+
the feature dimension of the vertices
|
| 24 |
+
d_e : int, default=DEFAULT_BOND_FDIM
|
| 25 |
+
the feature dimension of the edges
|
| 26 |
+
d_h : int, default=DEFAULT_HIDDEN_DIM
|
| 27 |
+
the hidden dimension during message passing
|
| 28 |
+
bias : bool, defuault=False
|
| 29 |
+
if `True`, add a bias term to the learned weight matrices
|
| 30 |
+
depth : int, default=3
|
| 31 |
+
the number of message passing iterations
|
| 32 |
+
undirected : bool, default=False
|
| 33 |
+
if `True`, pass messages on undirected edges
|
| 34 |
+
dropout : float, default=0.0
|
| 35 |
+
the dropout probability
|
| 36 |
+
activation : str, default="relu"
|
| 37 |
+
the activation function to use
|
| 38 |
+
d_vd : int | None, default=None
|
| 39 |
+
the dimension of additional vertex descriptors that will be concatenated to the hidden features before readout
|
| 40 |
+
|
| 41 |
+
See also
|
| 42 |
+
--------
|
| 43 |
+
* :class:`AtomMessagePassing`
|
| 44 |
+
|
| 45 |
+
* :class:`BondMessagePassing`
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(
|
| 49 |
+
self,
|
| 50 |
+
d_v: int = DEFAULT_ATOM_FDIM,
|
| 51 |
+
d_e: int = DEFAULT_BOND_FDIM,
|
| 52 |
+
d_h: int = DEFAULT_HIDDEN_DIM,
|
| 53 |
+
bias: bool = False,
|
| 54 |
+
depth: int = 3,
|
| 55 |
+
dropout: float = 0.0,
|
| 56 |
+
activation: str | Activation = Activation.RELU,
|
| 57 |
+
undirected: bool = False,
|
| 58 |
+
d_vd: int | None = None,
|
| 59 |
+
V_d_transform: ScaleTransform | None = None,
|
| 60 |
+
graph_transform: GraphTransform | None = None,
|
| 61 |
+
# layers_per_message: int = 1,
|
| 62 |
+
):
|
| 63 |
+
super().__init__()
|
| 64 |
+
self.save_hyperparameters()
|
| 65 |
+
self.hparams["cls"] = self.__class__
|
| 66 |
+
|
| 67 |
+
self.W_i, self.W_h, self.W_o, self.W_d = self.setup(d_v, d_e, d_h, d_vd, bias)
|
| 68 |
+
self.depth = depth
|
| 69 |
+
self.undirected = undirected
|
| 70 |
+
self.dropout = nn.Dropout(dropout)
|
| 71 |
+
self.tau = get_activation_function(activation)
|
| 72 |
+
self.V_d_transform = V_d_transform if V_d_transform is not None else nn.Identity()
|
| 73 |
+
self.graph_transform = graph_transform if graph_transform is not None else nn.Identity()
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def output_dim(self) -> int:
|
| 77 |
+
return self.W_d.out_features if self.W_d is not None else self.W_o.out_features
|
| 78 |
+
|
| 79 |
+
@abstractmethod
|
| 80 |
+
def setup(
|
| 81 |
+
self,
|
| 82 |
+
d_v: int = DEFAULT_ATOM_FDIM,
|
| 83 |
+
d_e: int = DEFAULT_BOND_FDIM,
|
| 84 |
+
d_h: int = DEFAULT_HIDDEN_DIM,
|
| 85 |
+
d_vd: int | None = None,
|
| 86 |
+
bias: bool = False,
|
| 87 |
+
) -> tuple[nn.Module, nn.Module, nn.Module, nn.Module | None]:
|
| 88 |
+
"""setup the weight matrices used in the message passing update functions
|
| 89 |
+
|
| 90 |
+
Parameters
|
| 91 |
+
----------
|
| 92 |
+
d_v : int
|
| 93 |
+
the vertex feature dimension
|
| 94 |
+
d_e : int
|
| 95 |
+
the edge feature dimension
|
| 96 |
+
d_h : int, default=300
|
| 97 |
+
the hidden dimension during message passing
|
| 98 |
+
d_vd : int | None, default=None
|
| 99 |
+
the dimension of additional vertex descriptors that will be concatenated to the hidden
|
| 100 |
+
features before readout, if any
|
| 101 |
+
bias: bool, default=False
|
| 102 |
+
whether to add a learned bias to the matrices
|
| 103 |
+
|
| 104 |
+
Returns
|
| 105 |
+
-------
|
| 106 |
+
W_i, W_h, W_o, W_d : tuple[nn.Module, nn.Module, nn.Module, nn.Module | None]
|
| 107 |
+
the input, hidden, output, and descriptor weight matrices, respectively, used in the
|
| 108 |
+
message passing update functions. The descriptor weight matrix is `None` if no vertex
|
| 109 |
+
dimension is supplied
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
@abstractmethod
|
| 113 |
+
def initialize(self, bmg: BatchMolGraph) -> Tensor:
|
| 114 |
+
"""initialize the message passing scheme by calculating initial matrix of hidden features"""
|
| 115 |
+
|
| 116 |
+
@abstractmethod
|
| 117 |
+
def message(self, H_t: Tensor, bmg: BatchMolGraph):
|
| 118 |
+
"""Calculate the message matrix"""
|
| 119 |
+
|
| 120 |
+
def update(self, M_t, H_0):
|
| 121 |
+
"""Calcualte the updated hidden for each edge"""
|
| 122 |
+
H_t = self.W_h(M_t)
|
| 123 |
+
H_t = self.tau(H_0 + H_t)
|
| 124 |
+
H_t = self.dropout(H_t)
|
| 125 |
+
|
| 126 |
+
return H_t
|
| 127 |
+
|
| 128 |
+
def finalize(self, M: Tensor, V: Tensor, V_d: Tensor | None) -> Tensor:
|
| 129 |
+
r"""Finalize message passing by (1) concatenating the final message ``M`` and the original
|
| 130 |
+
vertex features ``V`` and (2) if provided, further concatenating additional vertex
|
| 131 |
+
descriptors ``V_d``.
|
| 132 |
+
|
| 133 |
+
This function implements the following operation:
|
| 134 |
+
|
| 135 |
+
.. math::
|
| 136 |
+
H &= \mathtt{dropout} \left( \tau(\mathbf{W}_o(V \mathbin\Vert M)) \right) \\
|
| 137 |
+
H &= \mathtt{dropout} \left( \tau(\mathbf{W}_d(H \mathbin\Vert V_d)) \right),
|
| 138 |
+
|
| 139 |
+
where :math:`\tau` is the activation function, :math:`\Vert` is the concatenation operator,
|
| 140 |
+
:math:`\mathbf{W}_o` and :math:`\mathbf{W}_d` are learned weight matrices, :math:`M` is
|
| 141 |
+
the message matrix, :math:`V` is the original vertex feature matrix, and :math:`V_d` is an
|
| 142 |
+
optional vertex descriptor matrix.
|
| 143 |
+
|
| 144 |
+
Parameters
|
| 145 |
+
----------
|
| 146 |
+
M : Tensor
|
| 147 |
+
a tensor of shape ``V x d_h`` containing the message vector of each vertex
|
| 148 |
+
V : Tensor
|
| 149 |
+
a tensor of shape ``V x d_v`` containing the original vertex features
|
| 150 |
+
V_d : Tensor | None
|
| 151 |
+
an optional tensor of shape ``V x d_vd`` containing additional vertex descriptors
|
| 152 |
+
|
| 153 |
+
Returns
|
| 154 |
+
-------
|
| 155 |
+
Tensor
|
| 156 |
+
a tensor of shape ``V x (d_h + d_v [+ d_vd])`` containing the final hidden
|
| 157 |
+
representations
|
| 158 |
+
|
| 159 |
+
Raises
|
| 160 |
+
------
|
| 161 |
+
InvalidShapeError
|
| 162 |
+
if ``V_d`` is not of shape ``b x d_vd``, where ``b`` is the batch size and ``d_vd`` is
|
| 163 |
+
the vertex descriptor dimension
|
| 164 |
+
"""
|
| 165 |
+
H = self.W_o(torch.cat((V, M), dim=1)) # V x d_o
|
| 166 |
+
H = self.tau(H)
|
| 167 |
+
H = self.dropout(H)
|
| 168 |
+
|
| 169 |
+
if V_d is not None:
|
| 170 |
+
V_d = self.V_d_transform(V_d)
|
| 171 |
+
try:
|
| 172 |
+
H = self.W_d(torch.cat((H, V_d), dim=1)) # V x (d_o + d_vd)
|
| 173 |
+
H = self.dropout(H)
|
| 174 |
+
except RuntimeError:
|
| 175 |
+
raise InvalidShapeError("V_d", V_d.shape, [len(H), self.W_d.in_features])
|
| 176 |
+
|
| 177 |
+
return H
|
| 178 |
+
|
| 179 |
+
def forward(self, bmg: BatchMolGraph, V_d: Tensor | None = None) -> Tensor:
|
| 180 |
+
"""Encode a batch of molecular graphs.
|
| 181 |
+
|
| 182 |
+
Parameters
|
| 183 |
+
----------
|
| 184 |
+
bmg: BatchMolGraph
|
| 185 |
+
a batch of :class:`BatchMolGraph`s to encode
|
| 186 |
+
V_d : Tensor | None, default=None
|
| 187 |
+
an optional tensor of shape ``V x d_vd`` containing additional descriptors for each atom
|
| 188 |
+
in the batch. These will be concatenated to the learned atomic descriptors and
|
| 189 |
+
transformed before the readout phase.
|
| 190 |
+
|
| 191 |
+
Returns
|
| 192 |
+
-------
|
| 193 |
+
Tensor
|
| 194 |
+
a tensor of shape ``V x d_h`` or ``V x (d_h + d_vd)`` containing the encoding of each
|
| 195 |
+
molecule in the batch, depending on whether additional atom descriptors were provided
|
| 196 |
+
"""
|
| 197 |
+
bmg = self.graph_transform(bmg)
|
| 198 |
+
H_0 = self.initialize(bmg)
|
| 199 |
+
|
| 200 |
+
H = self.tau(H_0)
|
| 201 |
+
for _ in range(1, self.depth):
|
| 202 |
+
if self.undirected:
|
| 203 |
+
H = (H + H[bmg.rev_edge_index]) / 2
|
| 204 |
+
|
| 205 |
+
M = self.message(H, bmg)
|
| 206 |
+
H = self.update(M, H_0)
|
| 207 |
+
|
| 208 |
+
index_torch = bmg.edge_index[1].unsqueeze(1).repeat(1, H.shape[1])
|
| 209 |
+
M = torch.zeros(len(bmg.V), H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 210 |
+
0, index_torch, H, reduce="sum", include_self=False
|
| 211 |
+
)
|
| 212 |
+
return self.finalize(M, bmg.V, V_d)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class BondMessagePassing(_MessagePassingBase):
|
| 216 |
+
r"""A :class:`BondMessagePassing` encodes a batch of molecular graphs by passing messages along
|
| 217 |
+
directed bonds.
|
| 218 |
+
|
| 219 |
+
It implements the following operation:
|
| 220 |
+
|
| 221 |
+
.. math::
|
| 222 |
+
|
| 223 |
+
h_{vw}^{(0)} &= \tau \left( \mathbf W_i(e_{vw}) \right) \\
|
| 224 |
+
m_{vw}^{(t)} &= \sum_{u \in \mathcal N(v)\setminus w} h_{uv}^{(t-1)} \\
|
| 225 |
+
h_{vw}^{(t)} &= \tau \left(h_v^{(0)} + \mathbf W_h m_{vw}^{(t-1)} \right) \\
|
| 226 |
+
m_v^{(T)} &= \sum_{w \in \mathcal N(v)} h_w^{(T-1)} \\
|
| 227 |
+
h_v^{(T)} &= \tau \left (\mathbf W_o \left( x_v \mathbin\Vert m_{v}^{(T)} \right) \right),
|
| 228 |
+
|
| 229 |
+
where :math:`\tau` is the activation function; :math:`\mathbf W_i`, :math:`\mathbf W_h`, and
|
| 230 |
+
:math:`\mathbf W_o` are learned weight matrices; :math:`e_{vw}` is the feature vector of the
|
| 231 |
+
bond between atoms :math:`v` and :math:`w`; :math:`x_v` is the feature vector of atom :math:`v`;
|
| 232 |
+
:math:`h_{vw}^{(t)}` is the hidden representation of the bond :math:`v \rightarrow w` at
|
| 233 |
+
iteration :math:`t`; :math:`m_{vw}^{(t)}` is the message received by the bond :math:`v
|
| 234 |
+
\to w` at iteration :math:`t`; and :math:`t \in \{1, \dots, T-1\}` is the number of
|
| 235 |
+
message passing iterations.
|
| 236 |
+
"""
|
| 237 |
+
|
| 238 |
+
def setup(
|
| 239 |
+
self,
|
| 240 |
+
d_v: int = DEFAULT_ATOM_FDIM,
|
| 241 |
+
d_e: int = DEFAULT_BOND_FDIM,
|
| 242 |
+
d_h: int = DEFAULT_HIDDEN_DIM,
|
| 243 |
+
d_vd: int | None = None,
|
| 244 |
+
bias: bool = False,
|
| 245 |
+
):
|
| 246 |
+
W_i = nn.Linear(d_v + d_e, d_h, bias)
|
| 247 |
+
W_h = nn.Linear(d_h, d_h, bias)
|
| 248 |
+
W_o = nn.Linear(d_v + d_h, d_h)
|
| 249 |
+
W_d = nn.Linear(d_h + d_vd, d_h + d_vd) if d_vd is not None else None
|
| 250 |
+
|
| 251 |
+
return W_i, W_h, W_o, W_d
|
| 252 |
+
|
| 253 |
+
def initialize(self, bmg: BatchMolGraph) -> Tensor:
|
| 254 |
+
return self.W_i(torch.cat([bmg.V[bmg.edge_index[0]], bmg.E], dim=1))
|
| 255 |
+
|
| 256 |
+
def message(self, H: Tensor, bmg: BatchMolGraph) -> Tensor:
|
| 257 |
+
index_torch = bmg.edge_index[1].unsqueeze(1).repeat(1, H.shape[1])
|
| 258 |
+
M_all = torch.zeros(len(bmg.V), H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 259 |
+
0, index_torch, H, reduce="sum", include_self=False
|
| 260 |
+
)[bmg.edge_index[0]]
|
| 261 |
+
M_rev = H[bmg.rev_edge_index]
|
| 262 |
+
|
| 263 |
+
return M_all - M_rev
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class AtomMessagePassing(_MessagePassingBase):
|
| 267 |
+
r"""A :class:`AtomMessagePassing` encodes a batch of molecular graphs by passing messages along
|
| 268 |
+
atoms.
|
| 269 |
+
|
| 270 |
+
It implements the following operation:
|
| 271 |
+
|
| 272 |
+
.. math::
|
| 273 |
+
|
| 274 |
+
h_v^{(0)} &= \tau \left( \mathbf{W}_i(x_v) \right) \\
|
| 275 |
+
m_v^{(t)} &= \sum_{u \in \mathcal{N}(v)} h_u^{(t-1)} \mathbin\Vert e_{uv} \\
|
| 276 |
+
h_v^{(t)} &= \tau\left(h_v^{(0)} + \mathbf{W}_h m_v^{(t-1)}\right) \\
|
| 277 |
+
m_v^{(T)} &= \sum_{w \in \mathcal{N}(v)} h_w^{(T-1)} \\
|
| 278 |
+
h_v^{(T)} &= \tau \left (\mathbf{W}_o \left( x_v \mathbin\Vert m_{v}^{(T)} \right) \right),
|
| 279 |
+
|
| 280 |
+
where :math:`\tau` is the activation function; :math:`\mathbf{W}_i`, :math:`\mathbf{W}_h`, and
|
| 281 |
+
:math:`\mathbf{W}_o` are learned weight matrices; :math:`e_{vw}` is the feature vector of the
|
| 282 |
+
bond between atoms :math:`v` and :math:`w`; :math:`x_v` is the feature vector of atom :math:`v`;
|
| 283 |
+
:math:`h_v^{(t)}` is the hidden representation of atom :math:`v` at iteration :math:`t`;
|
| 284 |
+
:math:`m_v^{(t)}` is the message received by atom :math:`v` at iteration :math:`t`; and
|
| 285 |
+
:math:`t \in \{1, \dots, T\}` is the number of message passing iterations.
|
| 286 |
+
"""
|
| 287 |
+
|
| 288 |
+
def setup(
|
| 289 |
+
self,
|
| 290 |
+
d_v: int = DEFAULT_ATOM_FDIM,
|
| 291 |
+
d_e: int = DEFAULT_BOND_FDIM,
|
| 292 |
+
d_h: int = DEFAULT_HIDDEN_DIM,
|
| 293 |
+
d_vd: int | None = None,
|
| 294 |
+
bias: bool = False,
|
| 295 |
+
):
|
| 296 |
+
W_i = nn.Linear(d_v, d_h, bias)
|
| 297 |
+
W_h = nn.Linear(d_e + d_h, d_h, bias)
|
| 298 |
+
W_o = nn.Linear(d_v + d_h, d_h)
|
| 299 |
+
W_d = nn.Linear(d_h + d_vd, d_h + d_vd) if d_vd is not None else None
|
| 300 |
+
|
| 301 |
+
return W_i, W_h, W_o, W_d
|
| 302 |
+
|
| 303 |
+
def initialize(self, bmg: BatchMolGraph) -> Tensor:
|
| 304 |
+
return self.W_i(bmg.V[bmg.edge_index[0]])
|
| 305 |
+
|
| 306 |
+
def message(self, H: Tensor, bmg: BatchMolGraph):
|
| 307 |
+
H = torch.cat((H, bmg.E), dim=1)
|
| 308 |
+
index_torch = bmg.edge_index[1].unsqueeze(1).repeat(1, H.shape[1])
|
| 309 |
+
return torch.zeros(len(bmg.V), H.shape[1], dtype=H.dtype, device=H.device).scatter_reduce_(
|
| 310 |
+
0, index_torch, H, reduce="sum", include_self=False
|
| 311 |
+
)[bmg.edge_index[0]]
|
chemprop/nn/message_passing/multi.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Iterable, Sequence
|
| 2 |
+
import warnings
|
| 3 |
+
|
| 4 |
+
from torch import Tensor, nn
|
| 5 |
+
|
| 6 |
+
from chemprop.data import BatchMolGraph
|
| 7 |
+
from chemprop.nn.message_passing.proto import MessagePassing
|
| 8 |
+
from chemprop.nn.hparams import HasHParams
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MulticomponentMessagePassing(nn.Module, HasHParams):
|
| 12 |
+
"""A `MulticomponentMessagePassing` performs message-passing on each individual input in a
|
| 13 |
+
multicomponent input then concatenates the representation of each input to construct a
|
| 14 |
+
global representation
|
| 15 |
+
|
| 16 |
+
Parameters
|
| 17 |
+
----------
|
| 18 |
+
blocks : Sequence[MessagePassing]
|
| 19 |
+
the invidual message-passing blocks for each input
|
| 20 |
+
n_components : int
|
| 21 |
+
the number of components in each input
|
| 22 |
+
shared : bool, default=False
|
| 23 |
+
whether one block will be shared among all components in an input. If not, a separate
|
| 24 |
+
block will be learned for each component.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, blocks: Sequence[MessagePassing], n_components: int, shared: bool = False):
|
| 28 |
+
super().__init__()
|
| 29 |
+
self.hparams = {
|
| 30 |
+
"cls": self.__class__,
|
| 31 |
+
"blocks": [block.hparams for block in blocks],
|
| 32 |
+
"n_components": n_components,
|
| 33 |
+
"shared": shared,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if len(blocks) == 0:
|
| 37 |
+
raise ValueError("arg 'blocks' was empty!")
|
| 38 |
+
if shared and len(blocks) > 1:
|
| 39 |
+
warnings.warn(
|
| 40 |
+
"More than 1 block was supplied but 'shared' was True! Using only the 0th block..."
|
| 41 |
+
)
|
| 42 |
+
elif not shared and len(blocks) != n_components:
|
| 43 |
+
raise ValueError(
|
| 44 |
+
"arg 'n_components' must be equal to `len(blocks)` if 'shared' is False! "
|
| 45 |
+
f"got: {n_components} and {len(blocks)}, respectively."
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
self.n_components = n_components
|
| 49 |
+
self.shared = shared
|
| 50 |
+
self.blocks = nn.ModuleList([blocks[0]] * self.n_components if shared else blocks)
|
| 51 |
+
|
| 52 |
+
def __len__(self) -> int:
|
| 53 |
+
return len(self.blocks)
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def output_dim(self) -> int:
|
| 57 |
+
d_o = sum(block.output_dim for block in self.blocks)
|
| 58 |
+
|
| 59 |
+
return d_o
|
| 60 |
+
|
| 61 |
+
def forward(self, bmgs: Iterable[BatchMolGraph], V_ds: Iterable[Tensor | None]) -> list[Tensor]:
|
| 62 |
+
"""Encode the multicomponent inputs
|
| 63 |
+
|
| 64 |
+
Parameters
|
| 65 |
+
----------
|
| 66 |
+
bmgs : Iterable[BatchMolGraph]
|
| 67 |
+
V_ds : Iterable[Tensor | None]
|
| 68 |
+
|
| 69 |
+
Returns
|
| 70 |
+
-------
|
| 71 |
+
list[Tensor]
|
| 72 |
+
a list of tensors of shape `V x d_i` containing the respective encodings of the `i`\th
|
| 73 |
+
component, where `d_i` is the output dimension of the `i`\th encoder
|
| 74 |
+
"""
|
| 75 |
+
if V_ds is None:
|
| 76 |
+
return [block(bmg) for block, bmg in zip(self.blocks, bmgs)]
|
| 77 |
+
else:
|
| 78 |
+
return [block(bmg, V_d) for block, bmg, V_d in zip(self.blocks, bmgs, V_ds)]
|
chemprop/nn/message_passing/proto.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
|
| 3 |
+
from torch import nn, Tensor
|
| 4 |
+
|
| 5 |
+
from chemprop.data import BatchMolGraph
|
| 6 |
+
from chemprop.nn.hparams import HasHParams
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class MessagePassing(nn.Module, HasHParams):
|
| 10 |
+
"""A :class:`MessagePassing` module encodes a batch of molecular graphs
|
| 11 |
+
using message passing to learn vertex-level hidden representations."""
|
| 12 |
+
|
| 13 |
+
input_dim: int
|
| 14 |
+
output_dim: int
|
| 15 |
+
|
| 16 |
+
@abstractmethod
|
| 17 |
+
def forward(self, bmg: BatchMolGraph, V_d: Tensor | None = None) -> Tensor:
|
| 18 |
+
"""Encode a batch of molecular graphs.
|
| 19 |
+
|
| 20 |
+
Parameters
|
| 21 |
+
----------
|
| 22 |
+
bmg: BatchMolGraph
|
| 23 |
+
the batch of :class:`~chemprop.featurizers.molgraph.MolGraph`\s to encode
|
| 24 |
+
V_d : Tensor | None, default=None
|
| 25 |
+
an optional tensor of shape `V x d_vd` containing additional descriptors for each atom
|
| 26 |
+
in the batch. These will be concatenated to the learned atomic descriptors and
|
| 27 |
+
transformed before the readout phase.
|
| 28 |
+
|
| 29 |
+
Returns
|
| 30 |
+
-------
|
| 31 |
+
Tensor
|
| 32 |
+
a tensor of shape `V x d_h` or `V x (d_h + d_vd)` containing the hidden representation
|
| 33 |
+
of each vertex in the batch of graphs. The feature dimension depends on whether
|
| 34 |
+
additional atom descriptors were provided
|
| 35 |
+
"""
|
chemprop/nn/metrics.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from abc import abstractmethod
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
from torch import Tensor
|
| 6 |
+
from torchmetrics import functional as F
|
| 7 |
+
from torchmetrics.utilities.compute import auc
|
| 8 |
+
|
| 9 |
+
from chemprop.utils.registry import ClassRegistry
|
| 10 |
+
from chemprop.nn.loss import (
|
| 11 |
+
BCELoss,
|
| 12 |
+
BinaryMCCLoss,
|
| 13 |
+
CrossEntropyLoss,
|
| 14 |
+
LossFunction,
|
| 15 |
+
MSELoss,
|
| 16 |
+
MulticlassMCCLoss,
|
| 17 |
+
SIDLoss,
|
| 18 |
+
WassersteinLoss,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
__all__ = [
|
| 22 |
+
"Metric",
|
| 23 |
+
"MetricRegistry",
|
| 24 |
+
"ThresholdedMixin",
|
| 25 |
+
"MAEMetric",
|
| 26 |
+
"MSEMetric",
|
| 27 |
+
"RMSEMetric",
|
| 28 |
+
"BoundedMixin",
|
| 29 |
+
"BoundedMAEMetric",
|
| 30 |
+
"BoundedMSEMetric",
|
| 31 |
+
"BoundedRMSEMetric",
|
| 32 |
+
"R2Metric",
|
| 33 |
+
"BinaryAUROCMetric",
|
| 34 |
+
"BinaryAUPRCMetric",
|
| 35 |
+
"BinaryAccuracyMetric",
|
| 36 |
+
"BinaryF1Metric",
|
| 37 |
+
"BCEMetric",
|
| 38 |
+
"CrossEntropyMetric",
|
| 39 |
+
"BinaryMCCMetric",
|
| 40 |
+
"MulticlassMCCMetric",
|
| 41 |
+
"SIDMetric",
|
| 42 |
+
"WassersteinMetric",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class Metric(LossFunction):
|
| 47 |
+
"""
|
| 48 |
+
Parameters
|
| 49 |
+
----------
|
| 50 |
+
task_weights : ArrayLike = 1.0
|
| 51 |
+
.. important::
|
| 52 |
+
Ignored. Maintained for compatibility with :class:`~chemprop.nn.loss.LossFunction`
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
minimize: bool = True
|
| 56 |
+
|
| 57 |
+
def forward(
|
| 58 |
+
self,
|
| 59 |
+
preds: Tensor,
|
| 60 |
+
targets: Tensor,
|
| 61 |
+
mask: Tensor,
|
| 62 |
+
weights: Tensor,
|
| 63 |
+
lt_mask: Tensor,
|
| 64 |
+
gt_mask: Tensor,
|
| 65 |
+
):
|
| 66 |
+
return self._calc_unreduced_loss(preds, targets, mask, lt_mask, gt_mask)[mask].mean()
|
| 67 |
+
|
| 68 |
+
@abstractmethod
|
| 69 |
+
def _calc_unreduced_loss(self, preds, targets, mask, lt_mask, gt_mask) -> Tensor:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
MetricRegistry = ClassRegistry[Metric]()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class ThresholdedMixin:
|
| 78 |
+
threshold: float | None = 0.5
|
| 79 |
+
|
| 80 |
+
def extra_repr(self) -> str:
|
| 81 |
+
return f"threshold={self.threshold}"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@MetricRegistry.register("mae")
|
| 85 |
+
class MAEMetric(Metric):
|
| 86 |
+
def _calc_unreduced_loss(self, preds, targets, *args) -> Tensor:
|
| 87 |
+
return (preds - targets).abs()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@MetricRegistry.register("mse")
|
| 91 |
+
class MSEMetric(MSELoss, Metric):
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@MetricRegistry.register("rmse")
|
| 96 |
+
class RMSEMetric(MSEMetric):
|
| 97 |
+
def forward(
|
| 98 |
+
self,
|
| 99 |
+
preds: Tensor,
|
| 100 |
+
targets: Tensor,
|
| 101 |
+
mask: Tensor,
|
| 102 |
+
weights: Tensor,
|
| 103 |
+
lt_mask: Tensor,
|
| 104 |
+
gt_mask: Tensor,
|
| 105 |
+
):
|
| 106 |
+
squared_errors = super()._calc_unreduced_loss(preds, targets, mask, lt_mask, gt_mask)
|
| 107 |
+
|
| 108 |
+
return squared_errors[mask].mean().sqrt()
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class BoundedMixin:
|
| 112 |
+
def _calc_unreduced_loss(self, preds, targets, mask, lt_mask, gt_mask) -> Tensor:
|
| 113 |
+
preds = torch.where((preds < targets) & lt_mask, targets, preds)
|
| 114 |
+
preds = torch.where((preds > targets) & gt_mask, targets, preds)
|
| 115 |
+
|
| 116 |
+
return super()._calc_unreduced_loss(preds, targets, mask, lt_mask, gt_mask)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@MetricRegistry.register("bounded-mae")
|
| 120 |
+
class BoundedMAEMetric(MAEMetric, BoundedMixin):
|
| 121 |
+
pass
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@MetricRegistry.register("bounded-mse")
|
| 125 |
+
class BoundedMSEMetric(MSEMetric, BoundedMixin):
|
| 126 |
+
pass
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@MetricRegistry.register("bounded-rmse")
|
| 130 |
+
class BoundedRMSEMetric(RMSEMetric, BoundedMixin):
|
| 131 |
+
pass
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@MetricRegistry.register("r2")
|
| 135 |
+
class R2Metric(Metric):
|
| 136 |
+
minimize = False
|
| 137 |
+
|
| 138 |
+
def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs):
|
| 139 |
+
return F.r2_score(preds[mask], targets[mask])
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@MetricRegistry.register("roc")
|
| 143 |
+
class BinaryAUROCMetric(Metric):
|
| 144 |
+
minimize = False
|
| 145 |
+
|
| 146 |
+
def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs):
|
| 147 |
+
return self._calc_unreduced_loss(preds, targets, mask)
|
| 148 |
+
|
| 149 |
+
def _calc_unreduced_loss(self, preds, targets, mask, *args) -> Tensor:
|
| 150 |
+
return F.auroc(preds[mask], targets[mask].long(), task="binary")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@MetricRegistry.register("prc")
|
| 154 |
+
class BinaryAUPRCMetric(Metric):
|
| 155 |
+
minimize = False
|
| 156 |
+
|
| 157 |
+
def forward(self, preds: Tensor, targets: Tensor, *args, **kwargs):
|
| 158 |
+
p, r, _ = F.precision_recall_curve(preds, targets.long(), task="binary")
|
| 159 |
+
return auc(r, p)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@MetricRegistry.register("accuracy")
|
| 163 |
+
class BinaryAccuracyMetric(Metric, ThresholdedMixin):
|
| 164 |
+
minimize = False
|
| 165 |
+
|
| 166 |
+
def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs):
|
| 167 |
+
return F.accuracy(
|
| 168 |
+
preds[mask], targets[mask].long(), threshold=self.threshold, task="binary"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@MetricRegistry.register("f1")
|
| 173 |
+
class BinaryF1Metric(Metric, ThresholdedMixin):
|
| 174 |
+
minimize = False
|
| 175 |
+
|
| 176 |
+
def forward(self, preds: Tensor, targets: Tensor, mask: Tensor, *args, **kwargs):
|
| 177 |
+
return F.f1_score(
|
| 178 |
+
preds[mask], targets[mask].long(), threshold=self.threshold, task="binary"
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@MetricRegistry.register("bce")
|
| 183 |
+
class BCEMetric(BCELoss, Metric):
|
| 184 |
+
pass
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@MetricRegistry.register("ce")
|
| 188 |
+
class CrossEntropyMetric(CrossEntropyLoss, Metric):
|
| 189 |
+
pass
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@MetricRegistry.register("binary-mcc")
|
| 193 |
+
class BinaryMCCMetric(BinaryMCCLoss, Metric):
|
| 194 |
+
pass
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@MetricRegistry.register("multiclass-mcc")
|
| 198 |
+
class MulticlassMCCMetric(MulticlassMCCLoss, Metric):
|
| 199 |
+
pass
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@MetricRegistry.register("sid")
|
| 203 |
+
class SIDMetric(SIDLoss, Metric):
|
| 204 |
+
pass
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@MetricRegistry.register("wasserstein")
|
| 208 |
+
class WassersteinMetric(WassersteinLoss, Metric):
|
| 209 |
+
pass
|