LudwigO commited on
Commit
5716801
·
1 Parent(s): 16f9262

add chemprop files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +2 -4
  2. chemprop/__init__.py +5 -0
  3. chemprop/cli/common.py +183 -0
  4. chemprop/cli/conf.py +8 -0
  5. chemprop/cli/convert.py +55 -0
  6. chemprop/cli/fingerprint.py +197 -0
  7. chemprop/cli/hpopt.py +442 -0
  8. chemprop/cli/main.py +80 -0
  9. chemprop/cli/predict.py +315 -0
  10. chemprop/cli/train.py +1007 -0
  11. chemprop/cli/utils/__init__.py +31 -0
  12. chemprop/cli/utils/actions.py +28 -0
  13. chemprop/cli/utils/args.py +34 -0
  14. chemprop/cli/utils/command.py +24 -0
  15. chemprop/cli/utils/parsing.py +380 -0
  16. chemprop/cli/utils/utils.py +76 -0
  17. chemprop/conf.py +7 -0
  18. chemprop/data/__init__.py +34 -0
  19. chemprop/data/collate.py +120 -0
  20. chemprop/data/dataloader.py +69 -0
  21. chemprop/data/datapoints.py +169 -0
  22. chemprop/data/datasets.py +459 -0
  23. chemprop/data/molgraph.py +16 -0
  24. chemprop/data/samplers.py +66 -0
  25. chemprop/data/splitting.py +257 -0
  26. chemprop/exceptions.py +12 -0
  27. chemprop/featurizers/__init__.py +46 -0
  28. chemprop/featurizers/atom.py +222 -0
  29. chemprop/featurizers/base.py +30 -0
  30. chemprop/featurizers/bond.py +92 -0
  31. chemprop/featurizers/molecule.py +43 -0
  32. chemprop/featurizers/molgraph/__init__.py +13 -0
  33. chemprop/featurizers/molgraph/cache.py +89 -0
  34. chemprop/featurizers/molgraph/mixins.py +23 -0
  35. chemprop/featurizers/molgraph/molecule.py +95 -0
  36. chemprop/featurizers/molgraph/reaction.py +333 -0
  37. chemprop/models/__init__.py +5 -0
  38. chemprop/models/model.py +261 -0
  39. chemprop/models/multi.py +92 -0
  40. chemprop/models/utils.py +18 -0
  41. chemprop/nn/__init__.py +133 -0
  42. chemprop/nn/agg.py +132 -0
  43. chemprop/nn/ffn.py +63 -0
  44. chemprop/nn/hparams.py +38 -0
  45. chemprop/nn/loss.py +344 -0
  46. chemprop/nn/message_passing/__init__.py +10 -0
  47. chemprop/nn/message_passing/base.py +311 -0
  48. chemprop/nn/message_passing/multi.py +78 -0
  49. chemprop/nn/message_passing/proto.py +35 -0
  50. 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 $HOME/app/chemprop /data/chemprop
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 ./chemprop && \
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