forestgump 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forestgump/__init__.py +14 -0
- forestgump/baselines.py +180 -0
- forestgump/cli.py +109 -0
- forestgump/core.py +108 -0
- forestgump/demo.py +42 -0
- forestgump/featurize.py +29 -0
- forestgump/io.py +173 -0
- forestgump/jokes.py +81 -0
- forestgump/report.py +208 -0
- forestgump/stats.py +81 -0
- forestgump-0.1.0.dist-info/METADATA +157 -0
- forestgump-0.1.0.dist-info/RECORD +15 -0
- forestgump-0.1.0.dist-info/WHEEL +4 -0
- forestgump-0.1.0.dist-info/entry_points.txt +2 -0
- forestgump-0.1.0.dist-info/licenses/LICENSE +202 -0
forestgump/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""forestgump: did your model beat a random forest? Life is like a box of baselines."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("forestgump")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree
|
|
8
|
+
__version__ = "0+unknown"
|
|
9
|
+
|
|
10
|
+
from .core import run
|
|
11
|
+
from .io import InputError, load
|
|
12
|
+
from .report import Report
|
|
13
|
+
|
|
14
|
+
__all__ = ["InputError", "Report", "__version__", "load", "run"]
|
forestgump/baselines.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The baselines, their small grids, and grid selection on training rows only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from sklearn.dummy import DummyClassifier, DummyRegressor
|
|
10
|
+
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
|
|
11
|
+
from sklearn.model_selection import KFold, StratifiedKFold
|
|
12
|
+
from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor
|
|
13
|
+
|
|
14
|
+
from .featurize import Fingerprints
|
|
15
|
+
from .stats import LOWER_IS_BETTER, PRIMARY, metrics
|
|
16
|
+
|
|
17
|
+
N_TREES = 500
|
|
18
|
+
METHODS = ("mean", "knn", "forest")
|
|
19
|
+
METHODS_WITH_CONTEXT = ("mean", "context", "knn", "forest")
|
|
20
|
+
FOREST_SELECTION = ("oob", "inner-cv")
|
|
21
|
+
|
|
22
|
+
GRIDS: dict[str, list[dict]] = {
|
|
23
|
+
"mean": [{}],
|
|
24
|
+
"context": [{}],
|
|
25
|
+
"knn": [
|
|
26
|
+
{"radius": r, "n_neighbors": k, "weights": w}
|
|
27
|
+
for r, k, w in itertools.product((2, 3), (1, 3, 5, 10), ("uniform", "distance"))
|
|
28
|
+
],
|
|
29
|
+
"forest": [
|
|
30
|
+
{"radius": r, "n_estimators": N_TREES, "max_features": mf, "min_samples_leaf": leaf}
|
|
31
|
+
for r, mf, leaf in itertools.product((2, 3), ("sqrt", 0.3, 1.0), (1, 3))
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContextMean:
|
|
37
|
+
"""Predict the mean training target of the test row's context group, or the training mean for a group that has
|
|
38
|
+
no training rows. For a binary target that mean is the group's frequency of the larger class."""
|
|
39
|
+
|
|
40
|
+
def fit(self, codes: np.ndarray, y: np.ndarray):
|
|
41
|
+
self.fallback_ = float(np.mean(y))
|
|
42
|
+
self.means_ = {g: float(np.mean(y[codes == g])) for g in np.unique(codes)}
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def predict(self, codes: np.ndarray) -> np.ndarray:
|
|
46
|
+
return np.array([self.means_.get(g, self.fallback_) for g in codes], dtype=float)
|
|
47
|
+
|
|
48
|
+
def predict_proba(self, codes: np.ndarray) -> np.ndarray:
|
|
49
|
+
p = self.predict(codes)
|
|
50
|
+
return np.column_stack([1 - p, p])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _model(method: str, task: str, params: dict, n_train: int, seed: int, n_jobs: int):
|
|
54
|
+
binary = task == "binary"
|
|
55
|
+
if method == "context":
|
|
56
|
+
return ContextMean()
|
|
57
|
+
if method == "mean":
|
|
58
|
+
return DummyClassifier(strategy="prior") if binary else DummyRegressor(strategy="mean")
|
|
59
|
+
if method == "knn":
|
|
60
|
+
cls = KNeighborsClassifier if binary else KNeighborsRegressor
|
|
61
|
+
return cls(n_neighbors=min(params["n_neighbors"], n_train), weights=params["weights"], metric="precomputed")
|
|
62
|
+
cls = RandomForestClassifier if binary else RandomForestRegressor
|
|
63
|
+
return cls(
|
|
64
|
+
n_estimators=params["n_estimators"],
|
|
65
|
+
max_features=params["max_features"],
|
|
66
|
+
min_samples_leaf=params["min_samples_leaf"],
|
|
67
|
+
random_state=seed,
|
|
68
|
+
n_jobs=n_jobs,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def tanimoto_distance(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
73
|
+
"""1 - Tanimoto similarity between the rows of two bit matrices (scipy's Jaccard distance; 0 if both empty)."""
|
|
74
|
+
a, b = a.astype(np.float64), b.astype(np.float64)
|
|
75
|
+
shared = a @ b.T
|
|
76
|
+
union = a.sum(1)[:, None] + b.sum(1)[None, :] - shared
|
|
77
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
78
|
+
distance = (union - shared) / union # integer counts, so this rounds exactly as scipy's jaccard does
|
|
79
|
+
distance[union == 0] = 0.0
|
|
80
|
+
return distance
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fingerprints_for(fps: Fingerprints, params: dict) -> np.ndarray | None:
|
|
84
|
+
"""The bit matrix a config uses (all rows), or None for the mean baseline."""
|
|
85
|
+
return fps(params["radius"]) if "radius" in params else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _features(method: str, X: np.ndarray | None, train_idx: np.ndarray, test_idx: np.ndarray):
|
|
89
|
+
"""(X to fit on, X to predict). kNN gets precomputed Tanimoto distances to the training rows."""
|
|
90
|
+
if X is None:
|
|
91
|
+
# The mean baseline ignores X; one zero column keeps sklearn's input checks happy.
|
|
92
|
+
return np.zeros((len(train_idx), 1)), np.zeros((len(test_idx), 1))
|
|
93
|
+
if method == "knn":
|
|
94
|
+
return tanimoto_distance(X[train_idx], X[train_idx]), tanimoto_distance(X[test_idx], X[train_idx])
|
|
95
|
+
return X[train_idx], X[test_idx]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def predict(model, task: str, X: np.ndarray) -> np.ndarray:
|
|
99
|
+
return model.predict_proba(X)[:, 1] if task == "binary" else model.predict(X)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fit_predict(method, task, params, X, y, train_idx, test_idx, seed, n_jobs):
|
|
103
|
+
"""Fit one configuration on rows train_idx of X and predict rows test_idx. Returns (predictions, fitted model).
|
|
104
|
+
|
|
105
|
+
X is the config's fingerprint matrix over all rows (`fingerprints_for`), the per-row context group codes for the
|
|
106
|
+
context mean, or None for the mean baseline.
|
|
107
|
+
"""
|
|
108
|
+
model = _model(method, task, params, len(train_idx), seed, n_jobs)
|
|
109
|
+
X_fit, X_pred = _features(method, X, train_idx, test_idx)
|
|
110
|
+
model.fit(X_fit, y[train_idx])
|
|
111
|
+
return predict(model, task, X_pred), model
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _inner_cv_score(method, task, params, X, y, train_idx, seed, n_jobs=1) -> float:
|
|
115
|
+
"""Mean primary metric over inner CV folds of the training rows."""
|
|
116
|
+
scores = []
|
|
117
|
+
for a, b in _inner_splits(task, y[train_idx], seed).split(train_idx, y[train_idx]):
|
|
118
|
+
p, _ = fit_predict(method, task, params, X, y, train_idx[a], train_idx[b], seed, n_jobs)
|
|
119
|
+
scores.append(metrics(task, y[train_idx[b]], p)[PRIMARY[task]])
|
|
120
|
+
return float(np.mean(scores))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _inner_splits(task: str, y_train: np.ndarray, seed: int):
|
|
124
|
+
n_splits = 5 if len(y_train) >= 50 else 3
|
|
125
|
+
if task == "binary":
|
|
126
|
+
n_splits = min(n_splits, int(np.bincount(y_train.astype(int)).min()))
|
|
127
|
+
return StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
|
|
128
|
+
return KFold(n_splits=n_splits, shuffle=True, random_state=seed)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fit_forest_with_oob(task, params, X, y, train_idx, seed, n_jobs):
|
|
132
|
+
"""Fit one forest on the training rows; score it on its out-of-bag predictions. Returns (score, model)."""
|
|
133
|
+
model = _model("forest", task, params, len(train_idx), seed, n_jobs).set_params(oob_score=True)
|
|
134
|
+
model.fit(X[train_idx], y[train_idx])
|
|
135
|
+
oob = model.oob_decision_function_[:, 1] if task == "binary" else model.oob_prediction_
|
|
136
|
+
seen = np.isfinite(oob) # a row in every bootstrap sample has no out-of-bag prediction
|
|
137
|
+
return metrics(task, y[train_idx][seen], oob[seen])[PRIMARY[task]], model
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _better(primary: str, a: float, b: float) -> bool:
|
|
141
|
+
return a < b if primary in LOWER_IS_BETTER else a > b
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Selection:
|
|
146
|
+
"""The grid winner for one method on one cell, with every config's score on the training rows."""
|
|
147
|
+
|
|
148
|
+
params: dict
|
|
149
|
+
score: float
|
|
150
|
+
grid_scores: list[float] = field(default_factory=list)
|
|
151
|
+
model: object = None # the forest winner, already fitted on every training row
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def select(method, task, fps, y, train_idx, seed, n_jobs, forest_selection="oob") -> Selection:
|
|
155
|
+
"""Pick a method's config using only the rows in train_idx.
|
|
156
|
+
|
|
157
|
+
By default the forest fits each of its configs once on all training rows and is judged by its out-of-bag
|
|
158
|
+
score, which costs nothing extra; the winning forest is kept, so nothing is refit. With
|
|
159
|
+
forest_selection="inner-cv" it is judged by inner CV like kNN instead, then refit: k forests per config plus
|
|
160
|
+
one, about 5x the forest time. kNN fits take milliseconds, so it always uses inner CV. Ties go to the earlier
|
|
161
|
+
config in grid order, so selection is deterministic.
|
|
162
|
+
"""
|
|
163
|
+
grid = GRIDS[method]
|
|
164
|
+
primary = PRIMARY[task]
|
|
165
|
+
if len(grid) == 1:
|
|
166
|
+
return Selection(grid[0], np.nan, [np.nan])
|
|
167
|
+
best, scores = None, []
|
|
168
|
+
for params in grid:
|
|
169
|
+
X = fingerprints_for(fps, params)
|
|
170
|
+
if method == "forest" and forest_selection == "oob":
|
|
171
|
+
score, model = _fit_forest_with_oob(task, params, X, y, train_idx, seed, n_jobs)
|
|
172
|
+
elif method == "forest":
|
|
173
|
+
score, model = _inner_cv_score(method, task, params, X, y, train_idx, seed, n_jobs), None
|
|
174
|
+
else:
|
|
175
|
+
score, model = _inner_cv_score(method, task, params, X, y, train_idx, seed), None
|
|
176
|
+
scores.append(score)
|
|
177
|
+
if best is None or _better(primary, score, best.score):
|
|
178
|
+
best = Selection(params, score, model=model)
|
|
179
|
+
best.grid_scores = scores
|
|
180
|
+
return best
|
forestgump/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`forestgump run data.csv`: Run, Forest, run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import __version__, jokes
|
|
12
|
+
from .core import INNER_CV_WARNING, run
|
|
13
|
+
from .demo import MODELS, demo_frame
|
|
14
|
+
from .io import TASKS, InputError
|
|
15
|
+
|
|
16
|
+
EXIT_FOREST_WON = 1
|
|
17
|
+
EXIT_BAD_INPUT = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="forestgump", description=f"Did your model beat a random forest? {jokes.TAGLINE}"
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("--version", action="version", version=f"forestgump {__version__}. {jokes.TAGLINE}")
|
|
25
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
r = sub.add_parser(
|
|
27
|
+
"run",
|
|
28
|
+
help="fit the baselines on your folds and judge your model",
|
|
29
|
+
description="One CSV row per (molecule, repeat). The test set of (repeat r, fold f) is its rows; "
|
|
30
|
+
"the training set is every other row of repeat r. --pred is your model's prediction for the row "
|
|
31
|
+
"(for binary targets, the probability of the larger class label).",
|
|
32
|
+
)
|
|
33
|
+
r.add_argument("csv")
|
|
34
|
+
_shared(r)
|
|
35
|
+
r.add_argument("--smiles", default="smiles", help="SMILES column (default: smiles)")
|
|
36
|
+
r.add_argument("--target", default="target", help="label column (default: target)")
|
|
37
|
+
r.add_argument("--fold", default="fold", help="test-fold column (default: fold)")
|
|
38
|
+
r.add_argument("--pred", default="pred", help="your model's out-of-fold prediction column (default: pred)")
|
|
39
|
+
r.add_argument("--repeat", default=None, help="repeat column for repeated CV (default: one repeat)")
|
|
40
|
+
r.add_argument("--task", choices=TASKS, default=None, help="default: binary if the target has 2 values")
|
|
41
|
+
r.add_argument(
|
|
42
|
+
"--context",
|
|
43
|
+
nargs="+",
|
|
44
|
+
default=None,
|
|
45
|
+
metavar="COL",
|
|
46
|
+
help="column(s) defining a context group; adds the context mean, which predicts each group's training mean",
|
|
47
|
+
)
|
|
48
|
+
d = sub.add_parser("demo", help="judge a pretend model on 105 synthetic molecules labelled with Crippen logP")
|
|
49
|
+
d.add_argument("--model", choices=MODELS, default="noise", help="noise loses, oracle wins (default: noise)")
|
|
50
|
+
d.add_argument("--repeats", type=int, default=1, help="repeats of 5-fold CV (default: 1)")
|
|
51
|
+
_shared(d)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _shared(p: argparse.ArgumentParser) -> None:
|
|
56
|
+
p.add_argument("--seed", type=int, default=0)
|
|
57
|
+
p.add_argument("--n-jobs", type=int, default=-1, help="sklearn n_jobs (default: all cores)")
|
|
58
|
+
p.add_argument("--alpha", type=float, default=0.05)
|
|
59
|
+
p.add_argument(
|
|
60
|
+
"--brag", default=jokes.DEFAULT_BRAG, help=f'how your model was trained (default: "{jokes.DEFAULT_BRAG}")'
|
|
61
|
+
)
|
|
62
|
+
p.add_argument("--seriously", action="store_true", help="plain output, no jokes")
|
|
63
|
+
p.add_argument("--json", metavar="PATH", help="write every number to a JSON file")
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"--forest-inner-cv",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="pick the forest's hyperparameters by inner CV instead of out-of-bag score (about 5x slower)",
|
|
68
|
+
)
|
|
69
|
+
p.add_argument(
|
|
70
|
+
"--fail-if-forest-wins",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help=f"exit {EXIT_FOREST_WON} if the forest won, so a CI pipeline fails when your model loses",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
args = _parser().parse_args(argv)
|
|
78
|
+
if not args.seriously:
|
|
79
|
+
print("Planting the forest...", file=sys.stderr, flush=True)
|
|
80
|
+
if args.command == "demo":
|
|
81
|
+
frame = demo_frame(args.model, n_repeats=args.repeats, seed=args.seed)
|
|
82
|
+
columns = {"repeat": "repeat"}
|
|
83
|
+
else:
|
|
84
|
+
frame = pd.read_csv(args.csv)
|
|
85
|
+
columns = {k: getattr(args, k) for k in ("smiles", "target", "fold", "pred", "repeat", "task", "context")}
|
|
86
|
+
if args.forest_inner_cv:
|
|
87
|
+
print(f"forestgump: {INNER_CV_WARNING}", file=sys.stderr, flush=True)
|
|
88
|
+
try:
|
|
89
|
+
with warnings.catch_warnings():
|
|
90
|
+
warnings.filterwarnings("ignore", message="forest_selection='inner-cv'") # already printed above
|
|
91
|
+
report = run(
|
|
92
|
+
frame,
|
|
93
|
+
seed=args.seed,
|
|
94
|
+
n_jobs=args.n_jobs,
|
|
95
|
+
alpha=args.alpha,
|
|
96
|
+
forest_selection="inner-cv" if args.forest_inner_cv else "oob",
|
|
97
|
+
**columns,
|
|
98
|
+
)
|
|
99
|
+
except InputError as exc:
|
|
100
|
+
print(f"forestgump: {exc}", file=sys.stderr)
|
|
101
|
+
return EXIT_BAD_INPUT
|
|
102
|
+
print(report.render(seriously=args.seriously, brag=args.brag))
|
|
103
|
+
if args.json:
|
|
104
|
+
report.to_json(args.json)
|
|
105
|
+
return EXIT_FOREST_WON if args.fail_if_forest_wins and report.verdict == "lose" else 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
forestgump/core.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Run every baseline on every cell and compare it with the user's model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .baselines import FOREST_SELECTION, METHODS, METHODS_WITH_CONTEXT, fingerprints_for, fit_predict, predict, select
|
|
12
|
+
from .featurize import Fingerprints
|
|
13
|
+
from .io import load
|
|
14
|
+
from .report import Comparison, Report
|
|
15
|
+
from .stats import MIN_CELLS, PRIMARY, corrected_t, holm, improvement, metrics
|
|
16
|
+
|
|
17
|
+
INNER_CV_WARNING = (
|
|
18
|
+
"forest_selection='inner-cv' fits every forest config once per inner fold, then refits the winner: "
|
|
19
|
+
"about 5x slower than the default out-of-bag selection. The forest will take its time."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
df: pd.DataFrame,
|
|
25
|
+
*,
|
|
26
|
+
smiles: str = "smiles",
|
|
27
|
+
target: str = "target",
|
|
28
|
+
fold: str = "fold",
|
|
29
|
+
pred: str = "pred",
|
|
30
|
+
repeat: str | None = None,
|
|
31
|
+
task: str | None = None,
|
|
32
|
+
context: str | list[str] | None = None,
|
|
33
|
+
seed: int = 0,
|
|
34
|
+
n_jobs: int = -1,
|
|
35
|
+
alpha: float = 0.05,
|
|
36
|
+
forest_selection: str = "oob",
|
|
37
|
+
) -> Report:
|
|
38
|
+
"""Fit the mean, kNN and forest baselines on the user's folds and test the user's model against each.
|
|
39
|
+
|
|
40
|
+
See `forestgump.io.load` for the input contract. Hyperparameters are chosen per cell from that cell's training
|
|
41
|
+
rows only; test rows are only ever predicted. kNN always uses inner CV. The forest uses its out-of-bag score
|
|
42
|
+
by default (one fit per config); forest_selection="inner-cv" uses inner CV instead, at about 5x the time.
|
|
43
|
+
Naming `context` column(s) adds a fourth baseline, the context mean: each test row gets the mean training
|
|
44
|
+
target of the rows that share its context values, or the training mean if no training row does.
|
|
45
|
+
"""
|
|
46
|
+
if forest_selection not in FOREST_SELECTION:
|
|
47
|
+
raise ValueError(f"forest_selection must be one of {FOREST_SELECTION}, got {forest_selection!r}")
|
|
48
|
+
if forest_selection == "inner-cv":
|
|
49
|
+
warnings.warn(INNER_CV_WARNING, stacklevel=2)
|
|
50
|
+
ds = load(df, smiles=smiles, target=target, fold=fold, pred=pred, repeat=repeat, task=task, context=context)
|
|
51
|
+
fps = Fingerprints(ds.mols, ds.mol_index)
|
|
52
|
+
methods = METHODS if ds.context is None else METHODS_WITH_CONTEXT
|
|
53
|
+
rows, selections, predictions = [], [], []
|
|
54
|
+
forest_seconds, n_leaves = 0.0, 0
|
|
55
|
+
for i, cell in enumerate(ds.cells):
|
|
56
|
+
base = {"cell": i, "repeat": cell.repeat, "fold": cell.fold, "n_train": len(cell.train_idx)}
|
|
57
|
+
base["n_test"] = len(cell.test_idx)
|
|
58
|
+
rows.append(base | {"method": "user"} | metrics(ds.task, ds.y[cell.test_idx], ds.pred[cell.test_idx]))
|
|
59
|
+
for method in methods:
|
|
60
|
+
start = time.perf_counter()
|
|
61
|
+
sel = select(method, ds.task, fps, ds.y, cell.train_idx, seed, n_jobs, forest_selection)
|
|
62
|
+
if sel.model is None:
|
|
63
|
+
X = ds.context if method == "context" else fingerprints_for(fps, sel.params)
|
|
64
|
+
p, model = fit_predict(
|
|
65
|
+
method, ds.task, sel.params, X, ds.y, cell.train_idx, cell.test_idx, seed, n_jobs
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
model = sel.model
|
|
69
|
+
p = predict(model, ds.task, fingerprints_for(fps, sel.params)[cell.test_idx])
|
|
70
|
+
if method == "forest":
|
|
71
|
+
forest_seconds += time.perf_counter() - start
|
|
72
|
+
n_leaves = sum(int(t.get_n_leaves()) for t in model.estimators_)
|
|
73
|
+
rows.append(base | {"method": method} | metrics(ds.task, ds.y[cell.test_idx], p))
|
|
74
|
+
predictions.append(pd.DataFrame({"cell": i, "row": cell.test_idx, "method": method, "pred": p}))
|
|
75
|
+
selection = {"cell": i, "method": method, "params": sel.params, "selection_score": sel.score}
|
|
76
|
+
if method == "context":
|
|
77
|
+
seen = np.isin(ds.context[cell.test_idx], ds.context[cell.train_idx])
|
|
78
|
+
selection["n_test_unseen_context"] = int((~seen).sum())
|
|
79
|
+
selections.append(selection)
|
|
80
|
+
|
|
81
|
+
table = pd.DataFrame(rows)
|
|
82
|
+
primary = PRIMARY[ds.task]
|
|
83
|
+
wide = table.pivot(index="cell", columns="method", values=primary)
|
|
84
|
+
comparisons = []
|
|
85
|
+
if len(ds.cells) >= MIN_CELLS:
|
|
86
|
+
tests = [
|
|
87
|
+
corrected_t(improvement(primary, wide["user"].to_numpy(), wide[m].to_numpy()), ds.test_train_ratio, alpha)
|
|
88
|
+
for m in methods
|
|
89
|
+
]
|
|
90
|
+
for m, t, p_adj in zip(methods, tests, holm([t.p for t in tests]), strict=True):
|
|
91
|
+
comparisons.append(Comparison(method=m, test=t, p_holm=p_adj))
|
|
92
|
+
return Report(
|
|
93
|
+
task=ds.task,
|
|
94
|
+
primary=primary,
|
|
95
|
+
table=table,
|
|
96
|
+
selections=selections,
|
|
97
|
+
predictions=pd.concat(predictions, ignore_index=True),
|
|
98
|
+
comparisons=comparisons,
|
|
99
|
+
n_cells=len(ds.cells),
|
|
100
|
+
test_train_ratio=ds.test_train_ratio,
|
|
101
|
+
forest_seconds=forest_seconds,
|
|
102
|
+
forest_leaves=n_leaves,
|
|
103
|
+
alpha=alpha,
|
|
104
|
+
seed=seed,
|
|
105
|
+
classes=ds.classes,
|
|
106
|
+
forest_selection=forest_selection,
|
|
107
|
+
context=ds.context_columns,
|
|
108
|
+
)
|
forestgump/demo.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""A synthetic demo: para-disubstituted benzenes labelled with RDKit's Crippen logP.
|
|
2
|
+
|
|
3
|
+
Two pretend models are on offer. "noise" predicts at random, so the forest beats it. "oracle" predicts the true
|
|
4
|
+
label plus a little noise, so it beats the forest.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from rdkit import Chem
|
|
14
|
+
from rdkit.Chem import Crippen
|
|
15
|
+
|
|
16
|
+
SUBSTITUENTS = ("[H]", "C", "CC", "CCC", "O", "N", "F", "Cl", "Br", "C(=O)O", "C#N", "OC", "C(F)(F)F", "[N+](=O)[O-]")
|
|
17
|
+
MODELS = ("noise", "oracle")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def molecules() -> list[str]:
|
|
21
|
+
"""Unique canonical SMILES of every para-disubstituted benzene over SUBSTITUENTS (105 molecules)."""
|
|
22
|
+
out = set()
|
|
23
|
+
for a, b in itertools.combinations_with_replacement(SUBSTITUENTS, 2):
|
|
24
|
+
mol = Chem.MolFromSmiles(f"c1({a})ccc({b})cc1")
|
|
25
|
+
out.add(Chem.MolToSmiles(Chem.RemoveHs(mol)))
|
|
26
|
+
return sorted(out)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def demo_frame(model: str = "noise", n_folds: int = 5, n_repeats: int = 1, seed: int = 0) -> pd.DataFrame:
|
|
30
|
+
"""Columns smiles, target, repeat, fold, pred: ready for `forestgump.run(..., repeat="repeat")`."""
|
|
31
|
+
if model not in MODELS:
|
|
32
|
+
raise ValueError(f"model must be one of {MODELS}")
|
|
33
|
+
rng = np.random.default_rng(seed)
|
|
34
|
+
smiles = molecules()
|
|
35
|
+
y = np.array([Crippen.MolLogP(Chem.MolFromSmiles(s)) for s in smiles])
|
|
36
|
+
frames = []
|
|
37
|
+
for r in range(n_repeats):
|
|
38
|
+
fold = rng.permutation(np.arange(len(smiles)) % n_folds)
|
|
39
|
+
noise = rng.normal(0, 0.1 if model == "oracle" else 1.5, len(smiles))
|
|
40
|
+
pred = y + noise if model == "oracle" else y.mean() + noise
|
|
41
|
+
frames.append(pd.DataFrame({"smiles": smiles, "target": y, "repeat": r, "fold": fold, "pred": pred}))
|
|
42
|
+
return pd.concat(frames, ignore_index=True)
|
forestgump/featurize.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Morgan fingerprints: a message-passing network with zero trainable parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from rdkit.Chem import rdFingerprintGenerator
|
|
7
|
+
|
|
8
|
+
FP_SIZE = 2048
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def morgan_bits(mols, radius: int, fp_size: int = FP_SIZE) -> np.ndarray:
|
|
12
|
+
"""Return an (n_mols, fp_size) boolean matrix of Morgan bits."""
|
|
13
|
+
gen = rdFingerprintGenerator.GetMorganGenerator(radius=radius, fpSize=fp_size)
|
|
14
|
+
return np.stack([gen.GetFingerprintAsNumPy(m) for m in mols]).astype(bool)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Fingerprints:
|
|
18
|
+
"""Per-row Morgan bits, computed once per radius over the unique molecules."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, mols, mol_index: np.ndarray, fp_size: int = FP_SIZE):
|
|
21
|
+
self._mols = mols
|
|
22
|
+
self._mol_index = mol_index
|
|
23
|
+
self._fp_size = fp_size
|
|
24
|
+
self._cache: dict[int, np.ndarray] = {}
|
|
25
|
+
|
|
26
|
+
def __call__(self, radius: int) -> np.ndarray:
|
|
27
|
+
if radius not in self._cache:
|
|
28
|
+
self._cache[radius] = morgan_bits(self._mols, radius, self._fp_size)[self._mol_index]
|
|
29
|
+
return self._cache[radius]
|
forestgump/io.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Load the user's molecules, folds and predictions, and refuse anything that would make the comparison lie."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from rdkit import Chem, RDLogger
|
|
11
|
+
|
|
12
|
+
TASKS = ("regression", "binary")
|
|
13
|
+
MIN_TRAIN_ROWS = 6
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InputError(ValueError):
|
|
17
|
+
"""The input cannot be compared fairly. The message says which rows and why."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Cell:
|
|
22
|
+
"""One (repeat, fold) test set: the paired unit of every comparison."""
|
|
23
|
+
|
|
24
|
+
repeat: object
|
|
25
|
+
fold: object
|
|
26
|
+
train_idx: np.ndarray
|
|
27
|
+
test_idx: np.ndarray
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Dataset:
|
|
32
|
+
"""Validated input. Row order follows the input frame; `mol_index` maps each row to `mols`."""
|
|
33
|
+
|
|
34
|
+
task: str
|
|
35
|
+
y: np.ndarray
|
|
36
|
+
pred: np.ndarray
|
|
37
|
+
mols: tuple
|
|
38
|
+
mol_index: np.ndarray
|
|
39
|
+
cells: tuple[Cell, ...]
|
|
40
|
+
classes: tuple | None = None
|
|
41
|
+
context: np.ndarray | None = None # per-row group code over the context columns, if any were given
|
|
42
|
+
context_columns: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def test_train_ratio(self) -> float:
|
|
46
|
+
"""Mean n_test / n_train over cells, for the Nadeau-Bengio correction."""
|
|
47
|
+
return float(np.mean([len(c.test_idx) / len(c.train_idx) for c in self.cells]))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rows(mask: np.ndarray, limit: int = 10) -> str:
|
|
51
|
+
"""Name offending rows as 0-based positions in the input frame."""
|
|
52
|
+
rows = np.flatnonzero(mask)
|
|
53
|
+
shown = ", ".join(str(r) for r in rows[:limit])
|
|
54
|
+
return shown + (f", ... ({len(rows)} rows)" if len(rows) > limit else "")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_smiles(smiles: np.ndarray) -> tuple[tuple, np.ndarray]:
|
|
58
|
+
unique, inverse = np.unique(smiles.astype(str), return_inverse=True)
|
|
59
|
+
RDLogger.DisableLog("rdApp.*")
|
|
60
|
+
try:
|
|
61
|
+
mols = [Chem.MolFromSmiles(s) for s in unique]
|
|
62
|
+
finally:
|
|
63
|
+
RDLogger.EnableLog("rdApp.*")
|
|
64
|
+
bad = np.array([m is None for m in mols])
|
|
65
|
+
if bad.any():
|
|
66
|
+
raise InputError(f"RDKit could not parse SMILES in rows {_rows(bad[inverse])}: {list(unique[bad][:5])}")
|
|
67
|
+
return tuple(mols), inverse
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_task(target: pd.Series, task: str | None) -> tuple[str, np.ndarray, tuple | None]:
|
|
71
|
+
n_unique = target.nunique()
|
|
72
|
+
if task is None:
|
|
73
|
+
task = "binary" if n_unique == 2 else "regression"
|
|
74
|
+
if task not in TASKS:
|
|
75
|
+
raise InputError(f"task must be one of {TASKS}, got {task!r}")
|
|
76
|
+
if task == "binary":
|
|
77
|
+
if n_unique != 2:
|
|
78
|
+
raise InputError(f"task='binary' needs exactly 2 distinct target values, found {n_unique}")
|
|
79
|
+
classes = tuple(sorted(target.unique()))
|
|
80
|
+
return task, (target.to_numpy() == classes[1]).astype(float), classes
|
|
81
|
+
try:
|
|
82
|
+
y = pd.to_numeric(target, errors="raise").to_numpy(dtype=float)
|
|
83
|
+
except (ValueError, TypeError) as exc:
|
|
84
|
+
raise InputError(f"task='regression' needs a numeric target: {exc}") from None
|
|
85
|
+
return task, y, None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_cells(fold: pd.Series, repeat: pd.Series) -> tuple[Cell, ...]:
|
|
89
|
+
cells = []
|
|
90
|
+
for r in sorted(repeat.unique(), key=str):
|
|
91
|
+
in_repeat = (repeat == r).to_numpy()
|
|
92
|
+
folds = sorted(fold[in_repeat].unique(), key=str)
|
|
93
|
+
if len(folds) < 2:
|
|
94
|
+
raise InputError(f"repeat {r!r} has a single fold ({folds[0]!r}), so it has no training set")
|
|
95
|
+
for f in folds:
|
|
96
|
+
test = in_repeat & (fold == f).to_numpy()
|
|
97
|
+
cells.append(Cell(r, f, np.flatnonzero(in_repeat & ~test), np.flatnonzero(test)))
|
|
98
|
+
return tuple(cells)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_cells(cells: tuple[Cell, ...], task: str, y: np.ndarray) -> None:
|
|
102
|
+
for c in cells:
|
|
103
|
+
where = f"repeat {c.repeat!r}, fold {c.fold!r}"
|
|
104
|
+
if len(c.train_idx) < MIN_TRAIN_ROWS:
|
|
105
|
+
raise InputError(
|
|
106
|
+
f"{where} has {len(c.train_idx)} training rows; the forest needs at least {MIN_TRAIN_ROWS} "
|
|
107
|
+
"(it is a forest, not a shrub)"
|
|
108
|
+
)
|
|
109
|
+
if task == "binary":
|
|
110
|
+
train_counts = np.bincount(y[c.train_idx].astype(int), minlength=2)
|
|
111
|
+
if train_counts.min() < 2:
|
|
112
|
+
raise InputError(f"{where}: the training set needs at least 2 rows of each class, has {train_counts}")
|
|
113
|
+
if len(np.unique(y[c.test_idx])) < 2:
|
|
114
|
+
raise InputError(f"{where}: the test set has one class, so ROC-AUC is undefined. Use stratified folds.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load(
|
|
118
|
+
df: pd.DataFrame,
|
|
119
|
+
*,
|
|
120
|
+
smiles: str = "smiles",
|
|
121
|
+
target: str = "target",
|
|
122
|
+
fold: str = "fold",
|
|
123
|
+
pred: str = "pred",
|
|
124
|
+
repeat: str | None = None,
|
|
125
|
+
task: str | None = None,
|
|
126
|
+
context: str | Sequence[str] | None = None,
|
|
127
|
+
) -> Dataset:
|
|
128
|
+
"""Validate a frame with one row per (molecule, repeat) and return the paired test cells.
|
|
129
|
+
|
|
130
|
+
The test set of cell (r, f) is the rows with ``repeat == r`` and ``fold == f``; its training set is every
|
|
131
|
+
other row of repeat r. `pred` holds the user's model's prediction for the row when it was in the test set:
|
|
132
|
+
a value for regression, the probability of the larger class label for binary. `context` names the column(s)
|
|
133
|
+
whose combined values define a row's context group for the context-mean baseline.
|
|
134
|
+
"""
|
|
135
|
+
context_columns = (context,) if isinstance(context, str) else tuple(context or ())
|
|
136
|
+
columns = [smiles, target, fold, pred] + ([repeat] if repeat else []) + list(context_columns)
|
|
137
|
+
missing = [c for c in columns if c not in df.columns]
|
|
138
|
+
if missing:
|
|
139
|
+
raise InputError(f"missing columns {missing}; the frame has {list(df.columns)}")
|
|
140
|
+
df = df.reset_index(drop=True)
|
|
141
|
+
for c in columns:
|
|
142
|
+
if df[c].isna().any():
|
|
143
|
+
raise InputError(f"column {c!r} is empty in rows {_rows(df[c].isna().to_numpy())}")
|
|
144
|
+
|
|
145
|
+
task, y, classes = _resolve_task(df[target], task)
|
|
146
|
+
try:
|
|
147
|
+
p = pd.to_numeric(df[pred], errors="raise").to_numpy(dtype=float)
|
|
148
|
+
except (ValueError, TypeError) as exc:
|
|
149
|
+
raise InputError(f"column {pred!r} must be numeric: {exc}") from None
|
|
150
|
+
if task == "binary":
|
|
151
|
+
outside = (p < 0) | (p > 1)
|
|
152
|
+
if outside.any():
|
|
153
|
+
raise InputError(
|
|
154
|
+
f"for a binary target, {pred!r} must be the probability of class {classes[1]!r}; "
|
|
155
|
+
f"rows {_rows(outside)} are outside [0, 1]"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
mols, mol_index = _parse_smiles(df[smiles].to_numpy())
|
|
159
|
+
repeats = df[repeat] if repeat else pd.Series(0, index=df.index)
|
|
160
|
+
cells = _build_cells(df[fold], repeats)
|
|
161
|
+
_check_cells(cells, task, y)
|
|
162
|
+
codes = df.groupby(list(context_columns), sort=False).ngroup().to_numpy() if context_columns else None
|
|
163
|
+
return Dataset(
|
|
164
|
+
task=task,
|
|
165
|
+
y=y,
|
|
166
|
+
pred=p,
|
|
167
|
+
mols=mols,
|
|
168
|
+
mol_index=mol_index,
|
|
169
|
+
cells=cells,
|
|
170
|
+
classes=classes,
|
|
171
|
+
context=codes,
|
|
172
|
+
context_columns=context_columns,
|
|
173
|
+
)
|