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 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"]
@@ -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)
@@ -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
+ )