papyrus-scripts 3.0.1__tar.gz → 3.0.2__tar.gz
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.
- {papyrus_scripts-3.0.1/src/papyrus_scripts.egg-info → papyrus_scripts-3.0.2}/PKG-INFO +7 -1
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/README.md +6 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/__init__.py +1 -1
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/fingerprint.py +22 -1
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/modelling.py +9 -2
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/neuralnet.py +27 -9
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/oop.py +24 -3
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/preprocess.py +2 -5
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/subsim_search.py +14 -11
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/IO.py +12 -2
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2/src/papyrus_scripts.egg-info}/PKG-INFO +7 -1
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_modelling.py +161 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_neuralnet.py +156 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_oop.py +4 -2
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_oop_unit.py +70 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/LICENSE +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/pyproject.toml +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/setup.cfg +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/__main__.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/cli.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/download.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/matchRCSB.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/reader.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/UniprotMatch.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/__init__.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/aliases.json +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/links.json +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/mol_reader.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/SOURCES.txt +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/dependency_links.txt +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/entry_points.txt +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/requires.txt +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/top_level.txt +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_cli.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_download.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_fingerprint.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_io.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_matchRCSB.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_modelling_real_data.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_mol_reader.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_preprocess.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_reader.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_reader_offline.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_reader_real_data.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_subsim_search.py +0 -0
- {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_uniprot_match.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: papyrus_scripts
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.2
|
|
4
4
|
Summary: A collection of scripts to handle the Papyrus bioactivity dataset
|
|
5
5
|
Author: Brandon J. Bongers, Willem Jespers
|
|
6
6
|
Author-email: "Olivier J. M. Béquignon" <olivier.bequignon.maintainer@gmail.com>
|
|
@@ -104,6 +104,12 @@ Optional extras enable additional functionality:
|
|
|
104
104
|
> **Note:** `openbabel` (needed only for FP2/FP3/FP4 fingerprints) must be installed via conda-forge, not pip, when used alongside RDKit/FPSim2/cupy in the same environment: `conda install -c conda-forge openbabel`.
|
|
105
105
|
>
|
|
106
106
|
> **Note:** the `gpu` extra pins `cupy-cuda12x[ctk]`, whose `ctk` extra pulls in the CUDA 12.x toolkit as a pip package — no separate toolkit install needed, only an NVIDIA driver compatible with CUDA 12.x.
|
|
107
|
+
>
|
|
108
|
+
> **Note:** on Windows, plain `pip install papyrus-scripts[dnn]` installs a CPU-only PyTorch build — CUDA-enabled Windows wheels aren't published on PyPI, only on PyTorch's own index. Get GPU support with:
|
|
109
|
+
> ```
|
|
110
|
+
> pip install papyrus-scripts[dnn] --extra-index-url https://download.pytorch.org/whl/cu130
|
|
111
|
+
> ```
|
|
112
|
+
> (swap `cu130` for the CUDA tag matching your driver; see the [PyTorch install matrix](https://pytorch.org/get-started/locally/)). On Linux, the plain PyPI wheel already bundles CUDA, so no extra index is needed there.
|
|
107
113
|
|
|
108
114
|
## 🛠️ Requirements
|
|
109
115
|
|
|
@@ -52,6 +52,12 @@ Optional extras enable additional functionality:
|
|
|
52
52
|
> **Note:** `openbabel` (needed only for FP2/FP3/FP4 fingerprints) must be installed via conda-forge, not pip, when used alongside RDKit/FPSim2/cupy in the same environment: `conda install -c conda-forge openbabel`.
|
|
53
53
|
>
|
|
54
54
|
> **Note:** the `gpu` extra pins `cupy-cuda12x[ctk]`, whose `ctk` extra pulls in the CUDA 12.x toolkit as a pip package — no separate toolkit install needed, only an NVIDIA driver compatible with CUDA 12.x.
|
|
55
|
+
>
|
|
56
|
+
> **Note:** on Windows, plain `pip install papyrus-scripts[dnn]` installs a CPU-only PyTorch build — CUDA-enabled Windows wheels aren't published on PyPI, only on PyTorch's own index. Get GPU support with:
|
|
57
|
+
> ```
|
|
58
|
+
> pip install papyrus-scripts[dnn] --extra-index-url https://download.pytorch.org/whl/cu130
|
|
59
|
+
> ```
|
|
60
|
+
> (swap `cu130` for the CUDA tag matching your driver; see the [PyTorch install matrix](https://pytorch.org/get-started/locally/)). On Linux, the plain PyPI wheel already bundles CUDA, so no extra index is needed there.
|
|
55
61
|
|
|
56
62
|
## 🛠️ Requirements
|
|
57
63
|
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import contextlib
|
|
7
8
|
import hashlib
|
|
8
9
|
import json
|
|
10
|
+
import warnings
|
|
9
11
|
from abc import ABC, abstractmethod
|
|
10
12
|
from collections.abc import Callable
|
|
11
13
|
from typing import Any
|
|
@@ -20,8 +22,27 @@ try:
|
|
|
20
22
|
HAS_PYBEL = True
|
|
21
23
|
except ImportError: # pragma: no cover - only taken when openbabel isn't installed
|
|
22
24
|
HAS_PYBEL = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextlib.contextmanager
|
|
28
|
+
def _suppress_missing_cuda_path_warning():
|
|
29
|
+
"""Silence cupy's harmless "CUDA path could not be detected" warning, nothing else."""
|
|
30
|
+
with warnings.catch_warnings():
|
|
31
|
+
warnings.filterwarnings(
|
|
32
|
+
'ignore',
|
|
33
|
+
message=r'^CUDA path could not be detected\.',
|
|
34
|
+
category=UserWarning,
|
|
35
|
+
module=r'^cupy(\..*)?$',
|
|
36
|
+
)
|
|
37
|
+
yield
|
|
38
|
+
|
|
39
|
+
|
|
23
40
|
try:
|
|
24
|
-
|
|
41
|
+
# Importing any FPSim2 submodule runs FPSim2/__init__.py, which
|
|
42
|
+
# unconditionally imports cupy - this is the first FPSim2 import in the
|
|
43
|
+
# package's import chain, so the warning suppression belongs here.
|
|
44
|
+
with _suppress_missing_cuda_path_warning():
|
|
45
|
+
from FPSim2.FPSim2lib.utils import BitStrToIntList, PyPopcount
|
|
25
46
|
HAS_FPSIM2 = True # pragma: no cover - only taken when FPSim2 is installed
|
|
26
47
|
except ImportError:
|
|
27
48
|
HAS_FPSIM2 = False
|
|
@@ -377,7 +377,9 @@ def _fit_and_evaluate(data: pd.DataFrame,
|
|
|
377
377
|
crossvalidate_model's per-fold-plus-"Full model" dict
|
|
378
378
|
"""
|
|
379
379
|
if split_by.lower() == 'year':
|
|
380
|
-
|
|
380
|
+
# 'Year' may come in as string/object dtype
|
|
381
|
+
years = pd.to_numeric(data['Year'], errors='coerce')
|
|
382
|
+
test_set = data[years >= split_year]
|
|
381
383
|
if test_set.empty:
|
|
382
384
|
raise _InsufficientDataError(f'No test data for temporal split at {split_year}')
|
|
383
385
|
training_set = data[~data.index.isin(test_set.index)]
|
|
@@ -400,7 +402,8 @@ def _fit_and_evaluate(data: pd.DataFrame,
|
|
|
400
402
|
elif split_by.lower() == 'cluster':
|
|
401
403
|
if cluster_method is None:
|
|
402
404
|
raise RuntimeError('cluster_method missing despite qsar()/pcm() validating it upfront')
|
|
403
|
-
|
|
405
|
+
# errors='ignore': merge_on/target_id are already dropped from data by this point
|
|
406
|
+
groups = cluster_method.fit_predict(data.drop(columns=features_to_ignore, errors='ignore'))
|
|
404
407
|
training_set, test_set, training_groups, _ = train_test_proportional_group_split(data, groups,
|
|
405
408
|
test_set_size,
|
|
406
409
|
verbose=verbose)
|
|
@@ -450,6 +453,10 @@ def _fit_and_evaluate(data: pd.DataFrame,
|
|
|
450
453
|
random_state=random_state).to_pandas()
|
|
451
454
|
test_set.index = test_index
|
|
452
455
|
# Make sure enough data
|
|
456
|
+
# (applies to every split_by mode, not just 'year')
|
|
457
|
+
if training_set.shape[0] < folds:
|
|
458
|
+
raise _InsufficientDataError(
|
|
459
|
+
f'Not enough training data ({training_set.shape[0]} rows) for {folds} folds')
|
|
453
460
|
if model_type == 'classifier':
|
|
454
461
|
train_data_classes = Counter(training_set[endpoint])
|
|
455
462
|
if not np.all(np.array(list(train_data_classes.values())) > folds):
|
|
@@ -25,6 +25,7 @@ try:
|
|
|
25
25
|
from skorch.callbacks import Checkpoint, EarlyStopping, LRScheduler
|
|
26
26
|
from skorch.dataset import Dataset as SkorchDataset
|
|
27
27
|
from skorch.helper import predefined_split
|
|
28
|
+
from skorch.utils import to_tensor
|
|
28
29
|
from torch import nn
|
|
29
30
|
HAS_TORCH = True
|
|
30
31
|
except ImportError: # pragma: no cover - exercised only when torch/skorch aren't installed
|
|
@@ -151,6 +152,7 @@ class BaseNN:
|
|
|
151
152
|
# subclass (see SingleTaskNNClassifier etc.), which supplies the
|
|
152
153
|
# rest of this __init__ signature and the fit/predict_proba/
|
|
153
154
|
# initialize members used below - invisible to mypy from here.
|
|
155
|
+
kwargs.setdefault('device', _default_device()) # allow overriding via device= kwarg
|
|
154
156
|
super().__init__( # type: ignore[call-arg]
|
|
155
157
|
module=_MLP,
|
|
156
158
|
optimizer=torch.optim.Adam,
|
|
@@ -160,7 +162,6 @@ class BaseNN:
|
|
|
160
162
|
callbacks=callbacks,
|
|
161
163
|
callbacks__valid_acc=None, # replaced by our own early-stopping/checkpoint logic
|
|
162
164
|
predict_nonlinearity=None, # real value set per-subclass, see set_architecture()/__init__
|
|
163
|
-
device=_default_device(),
|
|
164
165
|
train_split=None, # require an explicit validation set, see set_validation()
|
|
165
166
|
**kwargs,
|
|
166
167
|
)
|
|
@@ -305,6 +306,7 @@ class SingleTaskNNRegressor(BaseNN, skorch.NeuralNetRegressor if HAS_TORCH else
|
|
|
305
306
|
|
|
306
307
|
def __init__(self, *args, **kwargs) -> None:
|
|
307
308
|
"""Neural Network regressor to predict a unique endpoint."""
|
|
309
|
+
_require_torch()
|
|
308
310
|
super().__init__(*args, criterion=nn.MSELoss, **kwargs)
|
|
309
311
|
|
|
310
312
|
def set_architecture(self, n_dim: int) -> None:
|
|
@@ -315,14 +317,28 @@ class SingleTaskNNRegressor(BaseNN, skorch.NeuralNetRegressor if HAS_TORCH else
|
|
|
315
317
|
self._dims = [n_dim, *self.hidden_layers, 1]
|
|
316
318
|
|
|
317
319
|
|
|
318
|
-
class
|
|
319
|
-
"""
|
|
320
|
+
class _MaskedMultiTaskLoss:
|
|
321
|
+
"""Mixin: excludes ``NaN`` targets from the loss, so sparse multi-task rows need no imputation."""
|
|
322
|
+
|
|
323
|
+
def get_loss(self, y_pred, y_true, X=None, training=False):
|
|
324
|
+
"""Mean loss over non-``NaN`` targets; requires criterion(reduction='none')."""
|
|
325
|
+
y_true = to_tensor(y_true, device=self.device)
|
|
326
|
+
mask = ~torch.isnan(y_true)
|
|
327
|
+
if isinstance(self.criterion_, torch.nn.Module):
|
|
328
|
+
self.criterion_.train(training)
|
|
329
|
+
elementwise = self.criterion_(y_pred, torch.where(mask, y_true, torch.zeros_like(y_true)))
|
|
330
|
+
return (elementwise * mask).sum() / mask.sum().clamp(min=1)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class MultiTaskNNClassifier(_MaskedMultiTaskLoss, BaseNN,
|
|
334
|
+
skorch.NeuralNetClassifier if HAS_TORCH else object): # type: ignore[misc]
|
|
335
|
+
"""Neural Network classifier to predict multiple (independent, binary) endpoints; supports ``NaN`` labels."""
|
|
320
336
|
|
|
321
337
|
def __init__(self, *args, **kwargs) -> None:
|
|
322
338
|
"""Neural Network classifier to predict multiple endpoints."""
|
|
323
|
-
|
|
324
|
-
#
|
|
325
|
-
super().__init__(*args, criterion=nn.BCEWithLogitsLoss, **kwargs)
|
|
339
|
+
_require_torch()
|
|
340
|
+
# Raw logits + BCEWithLogitsLoss (see SingleTaskNNClassifier); reduction='none' for masking.
|
|
341
|
+
super().__init__(*args, criterion=nn.BCEWithLogitsLoss, criterion__reduction='none', **kwargs)
|
|
326
342
|
self.predict_nonlinearity = torch.sigmoid
|
|
327
343
|
|
|
328
344
|
def set_architecture(self, n_dim: int, n_task: int) -> None:
|
|
@@ -350,12 +366,14 @@ class MultiTaskNNClassifier(BaseNN, skorch.NeuralNetClassifier if HAS_TORCH else
|
|
|
350
366
|
return np.round(self.predict_proba(X))
|
|
351
367
|
|
|
352
368
|
|
|
353
|
-
class MultiTaskNNRegressor(BaseNN,
|
|
354
|
-
|
|
369
|
+
class MultiTaskNNRegressor(_MaskedMultiTaskLoss, BaseNN,
|
|
370
|
+
skorch.NeuralNetRegressor if HAS_TORCH else object): # type: ignore[misc]
|
|
371
|
+
"""Neural Network regressor to predict multiple endpoints; supports ``NaN`` targets."""
|
|
355
372
|
|
|
356
373
|
def __init__(self, *args, **kwargs) -> None:
|
|
357
374
|
"""Neural Network regressor to predict multiple endpoints."""
|
|
358
|
-
|
|
375
|
+
_require_torch()
|
|
376
|
+
super().__init__(*args, criterion=nn.MSELoss, criterion__reduction='none', **kwargs) # 'none' for masking
|
|
359
377
|
|
|
360
378
|
def set_architecture(self, n_dim: int, n_task: int) -> None:
|
|
361
379
|
"""Set dimension of input and number of tasks to be predicted.
|
|
@@ -427,6 +427,7 @@ class PapyrusDataset:
|
|
|
427
427
|
chunksize: int | None = None,
|
|
428
428
|
keep_original_files: bool = False,
|
|
429
429
|
disk_margin: float = 0.10,
|
|
430
|
+
download_if_missing: bool = False,
|
|
430
431
|
) -> PapyrusDataset:
|
|
431
432
|
"""Create a :class:`PapyrusDataset` from an existing DataFrame.
|
|
432
433
|
|
|
@@ -443,6 +444,11 @@ class PapyrusDataset:
|
|
|
443
444
|
lazily through this dataset (default: False)
|
|
444
445
|
:param disk_margin: safety margin for any download later triggered
|
|
445
446
|
through this dataset (default: 0.10)
|
|
447
|
+
:param download_if_missing: download the protein-target file if not
|
|
448
|
+
found locally, instead of raising (default: False)
|
|
449
|
+
:raises FileNotFoundError | NotADirectoryError | OSError | ValueError:
|
|
450
|
+
if the protein-target file isn't found and *download_if_missing*
|
|
451
|
+
is False
|
|
446
452
|
:returns: a :class:`PapyrusDataset` wrapping *df*
|
|
447
453
|
"""
|
|
448
454
|
pv = _ensure_papyrus_version(version)
|
|
@@ -450,9 +456,24 @@ class PapyrusDataset:
|
|
|
450
456
|
df = pl.from_pandas(df)
|
|
451
457
|
dataset = PapyrusDataset.__new__(PapyrusDataset)
|
|
452
458
|
dataset.papyrus_bioactivity_data = df
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
459
|
+
try:
|
|
460
|
+
dataset.papyrus_protein_data = reader.read_protein_set(
|
|
461
|
+
source_path=source_path, version=pv,
|
|
462
|
+
)
|
|
463
|
+
except _NOT_AVAILABLE_LOCALLY:
|
|
464
|
+
if not download_if_missing:
|
|
465
|
+
raise
|
|
466
|
+
download.download_papyrus(
|
|
467
|
+
outdir=source_path,
|
|
468
|
+
version=pv.pystow_path_key, # not .version: must match the folder key reads use
|
|
469
|
+
nostereo=not is3d, stereo=is3d, only_pp=plusplus,
|
|
470
|
+
structures=False, descriptors=None,
|
|
471
|
+
progress=download_progress, disk_margin=disk_margin,
|
|
472
|
+
keep_xz=keep_original_files,
|
|
473
|
+
)
|
|
474
|
+
dataset.papyrus_protein_data = reader.read_protein_set(
|
|
475
|
+
source_path=source_path, version=pv,
|
|
476
|
+
)
|
|
456
477
|
dataset.papyrus_params = dict(
|
|
457
478
|
is3d=is3d, version=pv, plusplus=plusplus,
|
|
458
479
|
chunksize=chunksize, source_path=source_path,
|
|
@@ -26,7 +26,7 @@ DataOutput = pl.DataFrame | pl.LazyFrame
|
|
|
26
26
|
_JoinHow = Literal['inner', 'left', 'right', 'full', 'semi', 'anti', 'cross', 'outer']
|
|
27
27
|
_ConcatHow = Literal[
|
|
28
28
|
'vertical', 'vertical_relaxed', 'diagonal', 'diagonal_relaxed',
|
|
29
|
-
'horizontal', 'align', 'align_full', 'align_inner',
|
|
29
|
+
'horizontal', 'horizontal_extend', 'align', 'align_full', 'align_inner',
|
|
30
30
|
'align_left', 'align_right',
|
|
31
31
|
]
|
|
32
32
|
|
|
@@ -736,10 +736,7 @@ def keep_protein_class(
|
|
|
736
736
|
return data.filter(pl.lit(False))
|
|
737
737
|
|
|
738
738
|
# level_frames always share len(protein_data) rows, so this never pads.
|
|
739
|
-
|
|
740
|
-
# e.g. on Google Colab's pinned version); 'horizontal' is available
|
|
741
|
-
# everywhere and behaves identically here since heights always match.
|
|
742
|
-
split_classes = pl.concat(level_frames, how='horizontal')
|
|
739
|
+
split_classes = pl.concat(level_frames, how='horizontal_extend')
|
|
743
740
|
|
|
744
741
|
# Build the boolean mask over protein rows.
|
|
745
742
|
mask = pl.Series([False] * len(split_classes))
|
|
@@ -27,6 +27,8 @@ from rdkit import Chem
|
|
|
27
27
|
from rdkit.Chem.rdSubstructLibrary import CachedSmilesMolHolder, PatternHolder, SubstructLibrary
|
|
28
28
|
from tqdm.auto import tqdm
|
|
29
29
|
|
|
30
|
+
from .fingerprint import Fingerprint, MorganFingerprint, _suppress_missing_cuda_path_warning, get_fp_from_name
|
|
31
|
+
|
|
30
32
|
try:
|
|
31
33
|
import tables as tb
|
|
32
34
|
HAS_TABLES = True # pragma: no cover - exercised only with pytables installed
|
|
@@ -36,15 +38,16 @@ except ImportError:
|
|
|
36
38
|
try:
|
|
37
39
|
# This whole block only runs with FPSim2 installed; every line below the
|
|
38
40
|
# first import is unreachable otherwise (the first import raises first).
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
with _suppress_missing_cuda_path_warning():
|
|
42
|
+
from FPSim2.base import BaseEngine # pragma: no cover
|
|
43
|
+
from FPSim2.FPSim2 import FPSim2Engine
|
|
44
|
+
from FPSim2.io.backends.base import BaseStorageBackend # pragma: no cover
|
|
45
|
+
from FPSim2.io.backends.pytables import ( # pragma: no cover
|
|
46
|
+
BATCH_WRITE_SIZE,
|
|
47
|
+
calc_popcnt_bins_pytables,
|
|
48
|
+
create_schema,
|
|
49
|
+
)
|
|
50
|
+
from FPSim2.io.chem import load_molecule # pragma: no cover
|
|
48
51
|
HAS_FPSIM2 = True # pragma: no cover
|
|
49
52
|
except ImportError:
|
|
50
53
|
HAS_FPSIM2 = False
|
|
@@ -65,12 +68,12 @@ try:
|
|
|
65
68
|
# FPSim2.FPSim2Cuda unconditionally imports cupy, so this is kept
|
|
66
69
|
# separate from HAS_FPSIM2 above: a missing GPU stack must not disable
|
|
67
70
|
# CPU-only search.
|
|
68
|
-
|
|
71
|
+
with _suppress_missing_cuda_path_warning():
|
|
72
|
+
from FPSim2.FPSim2Cuda import FPSim2CudaEngine # pragma: no cover
|
|
69
73
|
except ImportError:
|
|
70
74
|
class FPSim2CudaEngine: # type: ignore[no-redef]
|
|
71
75
|
"""Stub for FPSim2.FPSim2Cuda.FPSim2CudaEngine when cupy is absent."""
|
|
72
76
|
|
|
73
|
-
from .fingerprint import Fingerprint, MorganFingerprint, get_fp_from_name
|
|
74
77
|
from .utils.IO import PapyrusVersion, _prefer_parquet, _set_root_folder, get_num_rows_in_file, locate_file
|
|
75
78
|
from .utils.mol_reader import MolSupplier
|
|
76
79
|
|
|
@@ -294,6 +294,11 @@ def to_polars_schema(dtypes: dict) -> dict:
|
|
|
294
294
|
return {col: to_polars_dtype(t) for col, t in dtypes.items()}
|
|
295
295
|
|
|
296
296
|
|
|
297
|
+
#: 'Year' is absent from shipped data_types.json (unlike 'all_years', a
|
|
298
|
+
#: semicolon-joined list, it's always a clean single integer or null).
|
|
299
|
+
_EXTRA_PAPYRUS_SCHEMA_OVERRIDES: dict = {'Year': pl.Int32}
|
|
300
|
+
|
|
301
|
+
|
|
297
302
|
def load_data_type_schemas(source_module: pystow.Module) -> dict:
|
|
298
303
|
"""Read a version folder's ``data_types.json`` and return ``{section: {col: polars_dtype}}``.
|
|
299
304
|
|
|
@@ -302,10 +307,14 @@ def load_data_type_schemas(source_module: pystow.Module) -> dict:
|
|
|
302
307
|
dtype_file = source_module.join(name='data_types.json')
|
|
303
308
|
with open(dtype_file) as fh:
|
|
304
309
|
raw = json.load(fh, cls=TypeDecoder)
|
|
305
|
-
|
|
310
|
+
schemas = {
|
|
306
311
|
key: to_polars_schema(val) if isinstance(val, dict) else val
|
|
307
312
|
for key, val in raw.items()
|
|
308
313
|
}
|
|
314
|
+
if 'papyrus' in schemas:
|
|
315
|
+
for col, dtype in _EXTRA_PAPYRUS_SCHEMA_OVERRIDES.items():
|
|
316
|
+
schemas['papyrus'].setdefault(col, dtype)
|
|
317
|
+
return schemas
|
|
309
318
|
|
|
310
319
|
|
|
311
320
|
# ---------------------------------------------------------------------------
|
|
@@ -1097,7 +1106,8 @@ def _downcast_integer_overrides(
|
|
|
1097
1106
|
``None`` if every eligible column downcast cleanly.
|
|
1098
1107
|
"""
|
|
1099
1108
|
for col, target in overrides.items():
|
|
1100
|
-
|
|
1109
|
+
# A shared schema may name a column one file variant lacks.
|
|
1110
|
+
if col not in chunk.columns or target not in _NULLABLE_INT_DTYPES or col in forced_float_cols:
|
|
1101
1111
|
continue
|
|
1102
1112
|
non_null = chunk[col].dropna()
|
|
1103
1113
|
if len(non_null) == 0 or (non_null % 1 == 0).all():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: papyrus_scripts
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.2
|
|
4
4
|
Summary: A collection of scripts to handle the Papyrus bioactivity dataset
|
|
5
5
|
Author: Brandon J. Bongers, Willem Jespers
|
|
6
6
|
Author-email: "Olivier J. M. Béquignon" <olivier.bequignon.maintainer@gmail.com>
|
|
@@ -104,6 +104,12 @@ Optional extras enable additional functionality:
|
|
|
104
104
|
> **Note:** `openbabel` (needed only for FP2/FP3/FP4 fingerprints) must be installed via conda-forge, not pip, when used alongside RDKit/FPSim2/cupy in the same environment: `conda install -c conda-forge openbabel`.
|
|
105
105
|
>
|
|
106
106
|
> **Note:** the `gpu` extra pins `cupy-cuda12x[ctk]`, whose `ctk` extra pulls in the CUDA 12.x toolkit as a pip package — no separate toolkit install needed, only an NVIDIA driver compatible with CUDA 12.x.
|
|
107
|
+
>
|
|
108
|
+
> **Note:** on Windows, plain `pip install papyrus-scripts[dnn]` installs a CPU-only PyTorch build — CUDA-enabled Windows wheels aren't published on PyPI, only on PyTorch's own index. Get GPU support with:
|
|
109
|
+
> ```
|
|
110
|
+
> pip install papyrus-scripts[dnn] --extra-index-url https://download.pytorch.org/whl/cu130
|
|
111
|
+
> ```
|
|
112
|
+
> (swap `cu130` for the CUDA tag matching your driver; see the [PyTorch install matrix](https://pytorch.org/get-started/locally/)). On Linux, the plain PyPI wheel already bundles CUDA, so no extra index is needed there.
|
|
107
113
|
|
|
108
114
|
## 🛠️ Requirements
|
|
109
115
|
|
|
@@ -271,6 +271,20 @@ class TestFitAndEvaluateSplitModes(unittest.TestCase):
|
|
|
271
271
|
))
|
|
272
272
|
self.assertIn('Test set', performance.index)
|
|
273
273
|
|
|
274
|
+
def test_cluster_split_tolerates_already_dropped_features_to_ignore_columns(self):
|
|
275
|
+
# Regression: features_to_ignore may list columns already dropped from data
|
|
276
|
+
from sklearn.cluster import KMeans
|
|
277
|
+
n = 12
|
|
278
|
+
data = pd.DataFrame({
|
|
279
|
+
'y': range(1, n + 1), 'Year': [2010] * n,
|
|
280
|
+
'f1': [0.0] * 6 + [10.0] * 6, 'f2': [0.0] * 6 + [10.0] * 6,
|
|
281
|
+
})
|
|
282
|
+
performance, _, _ = _fit_and_evaluate(**self._kwargs(
|
|
283
|
+
data, split_by='cluster', cluster_method=KMeans(n_clusters=2, random_state=0, n_init=1),
|
|
284
|
+
features_to_ignore=['id', 'target_id', 'y', 'Year'],
|
|
285
|
+
))
|
|
286
|
+
self.assertIn('Test set', performance.index)
|
|
287
|
+
|
|
274
288
|
def test_custom_split(self):
|
|
275
289
|
n = 10
|
|
276
290
|
data = pd.DataFrame({'y': range(1, n + 1), 'Year': [2010] * n, 'f1': range(n), 'f2': range(n)})
|
|
@@ -298,6 +312,61 @@ class TestFitAndEvaluateSplitModes(unittest.TestCase):
|
|
|
298
312
|
performance, _, _ = _fit_and_evaluate(**self._kwargs(data, yscramble=True))
|
|
299
313
|
self.assertIn('Test set', performance.index)
|
|
300
314
|
|
|
315
|
+
def test_random_split_without_scrambling_fits_a_model(self):
|
|
316
|
+
n = 20
|
|
317
|
+
data = pd.DataFrame({
|
|
318
|
+
'y': list(range(1, n + 1)), 'Year': [2010] * n,
|
|
319
|
+
'f1': range(n), 'f2': range(n),
|
|
320
|
+
})
|
|
321
|
+
performance, _, cv_models = _fit_and_evaluate(**self._kwargs(data, split_by='random'))
|
|
322
|
+
self.assertIn('Test set', performance.index)
|
|
323
|
+
self.assertIn('Full model', cv_models)
|
|
324
|
+
|
|
325
|
+
def test_yscramble_permutes_training_labels_relative_to_features(self):
|
|
326
|
+
# must actually permute y, not leave it untouched
|
|
327
|
+
n = 20
|
|
328
|
+
data = pd.DataFrame({
|
|
329
|
+
'y': list(range(1, n + 1)), 'Year': [2010] * n,
|
|
330
|
+
'f1': range(n), 'f2': range(n),
|
|
331
|
+
})
|
|
332
|
+
from src.papyrus_scripts import modelling as modelling_mod
|
|
333
|
+
original_cvm = modelling_mod.crossvalidate_model
|
|
334
|
+
captured: dict[str, pd.Series] = {}
|
|
335
|
+
|
|
336
|
+
def _recording_cvm(data_arg, *args, **kwargs):
|
|
337
|
+
captured['y'] = data_arg.iloc[:, 0].copy()
|
|
338
|
+
return original_cvm(data_arg, *args, **kwargs)
|
|
339
|
+
|
|
340
|
+
with patch('src.papyrus_scripts.modelling.crossvalidate_model', side_effect=_recording_cvm):
|
|
341
|
+
_fit_and_evaluate(**self._kwargs(data, yscramble=False))
|
|
342
|
+
unscrambled_y = captured['y']
|
|
343
|
+
_fit_and_evaluate(**self._kwargs(data, yscramble=True))
|
|
344
|
+
scrambled_y = captured['y']
|
|
345
|
+
self.assertEqual(sorted(unscrambled_y.tolist()), sorted(scrambled_y.tolist()))
|
|
346
|
+
self.assertFalse((unscrambled_y.values == scrambled_y.values).all())
|
|
347
|
+
|
|
348
|
+
def test_random_split_insufficient_training_data_for_folds_raises(self):
|
|
349
|
+
# regression: this check used to only apply to split_by='year'
|
|
350
|
+
n = 5
|
|
351
|
+
data = pd.DataFrame({
|
|
352
|
+
'y': range(n), 'Year': [2010] * n, 'f1': range(n), 'f2': range(n),
|
|
353
|
+
})
|
|
354
|
+
with self.assertRaises(_InsufficientDataError):
|
|
355
|
+
_fit_and_evaluate(**self._kwargs(data, split_by='random', test_set_size=0.3, folds=5))
|
|
356
|
+
|
|
357
|
+
def test_cluster_split_insufficient_training_data_for_folds_raises(self):
|
|
358
|
+
from sklearn.cluster import KMeans
|
|
359
|
+
n = 5
|
|
360
|
+
data = pd.DataFrame({
|
|
361
|
+
'y': range(n), 'Year': [2010] * n,
|
|
362
|
+
'f1': [0.0, 0.0, 10.0, 10.0, 10.0], 'f2': [0.0, 0.0, 10.0, 10.0, 10.0],
|
|
363
|
+
})
|
|
364
|
+
with self.assertRaises(_InsufficientDataError):
|
|
365
|
+
_fit_and_evaluate(**self._kwargs(
|
|
366
|
+
data, split_by='cluster', folds=5,
|
|
367
|
+
cluster_method=KMeans(n_clusters=2, random_state=0, n_init=1),
|
|
368
|
+
))
|
|
369
|
+
|
|
301
370
|
def test_stratified_classifier_scale_return_val(self):
|
|
302
371
|
n = 20
|
|
303
372
|
data = pd.DataFrame({
|
|
@@ -434,6 +503,98 @@ class TestQsarPcmValidation(unittest.TestCase):
|
|
|
434
503
|
with self.assertRaises(ValueError):
|
|
435
504
|
pcm(data, num_points=3, split_year=2030)
|
|
436
505
|
|
|
506
|
+
def test_pcm_single_protein_across_multiple_compounds(self):
|
|
507
|
+
# _make_bioactivity_data() has a single target (T1) for all rows
|
|
508
|
+
with (
|
|
509
|
+
patch(
|
|
510
|
+
'src.papyrus_scripts.modelling.read_molecular_descriptors',
|
|
511
|
+
return_value=pl.LazyFrame(_make_descriptors()),
|
|
512
|
+
),
|
|
513
|
+
patch(
|
|
514
|
+
'src.papyrus_scripts.modelling.read_protein_descriptors',
|
|
515
|
+
return_value=pd.DataFrame({'target_id': ['T1'], 'Prot_1': [0.0]}),
|
|
516
|
+
) as mock_prot,
|
|
517
|
+
):
|
|
518
|
+
performance, return_val = pcm(
|
|
519
|
+
pd.DataFrame(_make_bioactivity_data()), num_points=3, folds=2, split_year=2016,
|
|
520
|
+
)
|
|
521
|
+
mock_prot.assert_called_once()
|
|
522
|
+
self.assertIn('Test set', performance.index)
|
|
523
|
+
self.assertIn('Full model', return_val)
|
|
524
|
+
|
|
525
|
+
def test_qsar_random_split_completes(self):
|
|
526
|
+
with patch(
|
|
527
|
+
'src.papyrus_scripts.modelling.read_molecular_descriptors',
|
|
528
|
+
return_value=pl.LazyFrame(_make_descriptors()),
|
|
529
|
+
):
|
|
530
|
+
results, _ = qsar(pd.DataFrame(_make_bioactivity_data()), model=DecisionTreeRegressor(random_state=0),
|
|
531
|
+
num_points=3, folds=2, split_by='random', verbose=False)
|
|
532
|
+
self.assertIn('Test set', results.loc['T1'].index)
|
|
533
|
+
|
|
534
|
+
def test_pcm_random_split_completes(self):
|
|
535
|
+
with (
|
|
536
|
+
patch(
|
|
537
|
+
'src.papyrus_scripts.modelling.read_molecular_descriptors',
|
|
538
|
+
return_value=pl.LazyFrame(_make_descriptors()),
|
|
539
|
+
),
|
|
540
|
+
patch(
|
|
541
|
+
'src.papyrus_scripts.modelling.read_protein_descriptors',
|
|
542
|
+
return_value=pd.DataFrame({'target_id': ['T1'], 'Prot_1': [0.0]}),
|
|
543
|
+
),
|
|
544
|
+
):
|
|
545
|
+
performance, _ = pcm(pd.DataFrame(_make_bioactivity_data()), model=DecisionTreeRegressor(random_state=0),
|
|
546
|
+
num_points=3, folds=2, split_by='random', verbose=False)
|
|
547
|
+
self.assertIn('Test set', performance.index)
|
|
548
|
+
|
|
549
|
+
def test_pcm_random_split_insufficient_data_raises_valueerror_not_raw_sklearn_error(self):
|
|
550
|
+
# regression: same fix as test_random_split_insufficient_training_data_for_folds_raises
|
|
551
|
+
n = 5
|
|
552
|
+
data = pd.DataFrame({
|
|
553
|
+
'connectivity': [f'C{i}' for i in range(n)],
|
|
554
|
+
'target_id': ['T1'] * n,
|
|
555
|
+
'Activity_class': [None] * n,
|
|
556
|
+
'pchembl_value_Mean': [5.0, 5.2, 5.4, 7.0, 7.6],
|
|
557
|
+
'relation': ['='] * n,
|
|
558
|
+
'Year': [2010] * n,
|
|
559
|
+
})
|
|
560
|
+
with (
|
|
561
|
+
patch(
|
|
562
|
+
'src.papyrus_scripts.modelling.read_molecular_descriptors',
|
|
563
|
+
return_value=pl.LazyFrame({'connectivity': [f'C{i}' for i in range(n)],
|
|
564
|
+
'Desc_1': [i / n for i in range(n)]}),
|
|
565
|
+
),
|
|
566
|
+
patch(
|
|
567
|
+
'src.papyrus_scripts.modelling.read_protein_descriptors',
|
|
568
|
+
return_value=pd.DataFrame({'target_id': ['T1'], 'Prot_1': [0.0]}),
|
|
569
|
+
),
|
|
570
|
+
):
|
|
571
|
+
with self.assertRaisesRegex(ValueError, 'Not enough training data'):
|
|
572
|
+
pcm(data, num_points=3, folds=5, split_by='random', verbose=False)
|
|
573
|
+
|
|
574
|
+
def test_qsar_random_split_insufficient_data_for_one_target_skips_it_not_the_whole_run(self):
|
|
575
|
+
# regression: used to abort the whole run instead of skipping T2
|
|
576
|
+
t1 = _make_bioactivity_data()
|
|
577
|
+
n2 = 5
|
|
578
|
+
t2 = {
|
|
579
|
+
'connectivity': [f'D{i}' for i in range(n2)],
|
|
580
|
+
'target_id': ['T2'] * n2,
|
|
581
|
+
'Activity_class': [None] * n2,
|
|
582
|
+
'pchembl_value_Mean': [5.0, 5.2, 5.4, 7.0, 7.6],
|
|
583
|
+
'relation': ['='] * n2,
|
|
584
|
+
'Year': [2010] * n2,
|
|
585
|
+
}
|
|
586
|
+
data = pd.DataFrame({key: list(t1[key]) + t2[key] for key in t1})
|
|
587
|
+
descs = pl.LazyFrame({
|
|
588
|
+
'connectivity': _make_descriptors()['connectivity'] + t2['connectivity'],
|
|
589
|
+
'Desc_1': [i / (16 + n2) for i in range(16 + n2)],
|
|
590
|
+
})
|
|
591
|
+
with patch('src.papyrus_scripts.modelling.read_molecular_descriptors', return_value=descs):
|
|
592
|
+
results, models = qsar(data, model=DecisionTreeRegressor(random_state=0),
|
|
593
|
+
num_points=3, folds=5, split_by='random', verbose=False)
|
|
594
|
+
self.assertIn('Test set', results.loc['T1'].index)
|
|
595
|
+
self.assertIsNone(models['T2'])
|
|
596
|
+
self.assertIn('Not enough training data', results.loc['T2', 'error'].iloc[0])
|
|
597
|
+
|
|
437
598
|
|
|
438
599
|
class TestModelMetricsMCC(unittest.TestCase):
|
|
439
600
|
"""Regression tests: model_metrics wrapped MCC computation in a
|
|
@@ -185,6 +185,94 @@ class TestDefaultDevice(unittest.TestCase):
|
|
|
185
185
|
self.assertEqual(nn_mod._default_device(), 'cpu')
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
@unittest.skipUnless(TORCH_AVAILABLE, 'requires torch and skorch')
|
|
189
|
+
class TestDeviceOverride(unittest.TestCase):
|
|
190
|
+
"""Regression: passing device= used to collide with the hardcoded default."""
|
|
191
|
+
|
|
192
|
+
def test_explicit_device_kwarg_is_not_swallowed(self):
|
|
193
|
+
with tempfile.TemporaryDirectory() as d:
|
|
194
|
+
reg = SingleTaskNNRegressor(d, epochs=2, device='cpu')
|
|
195
|
+
self.assertEqual(reg.device, 'cpu')
|
|
196
|
+
|
|
197
|
+
def test_omitting_device_still_defaults_via_default_device(self):
|
|
198
|
+
with tempfile.TemporaryDirectory() as d:
|
|
199
|
+
reg = SingleTaskNNRegressor(d, epochs=2)
|
|
200
|
+
self.assertEqual(reg.device, nn_mod._default_device())
|
|
201
|
+
|
|
202
|
+
@unittest.skipIf(nn_mod.torch.cuda.is_available(), 'meaningful only when no GPU is present')
|
|
203
|
+
def test_requesting_cuda_when_unavailable_raises_at_fit(self):
|
|
204
|
+
# must fail loudly at fit(), not silently fall back to CPU
|
|
205
|
+
rng = _rng()
|
|
206
|
+
X, y = pd.DataFrame(rng.random((12, 4))), pd.Series(rng.random(12))
|
|
207
|
+
X_valid, y_valid = pd.DataFrame(rng.random((4, 4))), pd.Series(rng.random(4))
|
|
208
|
+
with tempfile.TemporaryDirectory() as d:
|
|
209
|
+
reg = SingleTaskNNRegressor(d, epochs=2, early_stop=2, hidden_layers=[4],
|
|
210
|
+
device='cuda', verbose=0)
|
|
211
|
+
reg.set_architecture(4)
|
|
212
|
+
reg.set_validation(X_valid, y_valid)
|
|
213
|
+
with self.assertRaises((RuntimeError, AssertionError)):
|
|
214
|
+
reg.fit(X, y)
|
|
215
|
+
|
|
216
|
+
@unittest.skipIf(nn_mod.torch.cuda.is_available(), 'meaningful only when no GPU is present')
|
|
217
|
+
def test_set_params_cuda_after_construction_raises_at_fit(self):
|
|
218
|
+
rng = _rng()
|
|
219
|
+
X, y = pd.DataFrame(rng.random((12, 4))), pd.Series(rng.random(12))
|
|
220
|
+
X_valid, y_valid = pd.DataFrame(rng.random((4, 4))), pd.Series(rng.random(4))
|
|
221
|
+
with tempfile.TemporaryDirectory() as d:
|
|
222
|
+
reg = SingleTaskNNRegressor(d, epochs=2, early_stop=2, hidden_layers=[4], verbose=0)
|
|
223
|
+
reg.set_params(device='cuda')
|
|
224
|
+
reg.set_architecture(4)
|
|
225
|
+
reg.set_validation(X_valid, y_valid)
|
|
226
|
+
with self.assertRaises((RuntimeError, AssertionError)):
|
|
227
|
+
reg.fit(X, y)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@unittest.skipUnless(TORCH_AVAILABLE, 'requires torch and skorch')
|
|
231
|
+
class TestBatchSize(unittest.TestCase):
|
|
232
|
+
|
|
233
|
+
def _fit(self, *, batch_size, n_train, tmpdir):
|
|
234
|
+
rng = _rng()
|
|
235
|
+
X = pd.DataFrame(rng.random((n_train, 4)))
|
|
236
|
+
y = pd.Series(rng.random(n_train))
|
|
237
|
+
X_valid = pd.DataFrame(rng.random((4, 4)))
|
|
238
|
+
y_valid = pd.Series(rng.random(4))
|
|
239
|
+
reg = SingleTaskNNRegressor(tmpdir, epochs=2, early_stop=2, hidden_layers=[4],
|
|
240
|
+
batch_size=batch_size, verbose=0)
|
|
241
|
+
reg.set_architecture(4)
|
|
242
|
+
reg.set_validation(X_valid, y_valid)
|
|
243
|
+
reg.fit(X, y)
|
|
244
|
+
return reg, X
|
|
245
|
+
|
|
246
|
+
def test_batch_size_of_one_does_not_raise(self):
|
|
247
|
+
with tempfile.TemporaryDirectory() as d:
|
|
248
|
+
reg, X = self._fit(batch_size=1, n_train=8, tmpdir=d)
|
|
249
|
+
self.assertEqual(reg.predict(X).shape, (8, 1))
|
|
250
|
+
|
|
251
|
+
def test_batch_size_larger_than_training_set_does_not_raise(self):
|
|
252
|
+
with tempfile.TemporaryDirectory() as d:
|
|
253
|
+
reg, X = self._fit(batch_size=1024, n_train=8, tmpdir=d)
|
|
254
|
+
self.assertEqual(reg.predict(X).shape, (8, 1))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@unittest.skipUnless(TORCH_AVAILABLE, 'requires torch and skorch')
|
|
258
|
+
class TestTinyTrainingSet(unittest.TestCase):
|
|
259
|
+
|
|
260
|
+
def test_two_training_and_two_validation_samples_does_not_raise(self):
|
|
261
|
+
rng = _rng()
|
|
262
|
+
X = pd.DataFrame(rng.random((2, 4)))
|
|
263
|
+
y = pd.Series(rng.random(2))
|
|
264
|
+
X_valid = pd.DataFrame(rng.random((2, 4)))
|
|
265
|
+
y_valid = pd.Series(rng.random(2))
|
|
266
|
+
with tempfile.TemporaryDirectory() as d:
|
|
267
|
+
reg = SingleTaskNNRegressor(d, epochs=2, early_stop=2, batch_size=1,
|
|
268
|
+
hidden_layers=[4], verbose=0)
|
|
269
|
+
reg.set_architecture(4)
|
|
270
|
+
reg.set_validation(X_valid, y_valid)
|
|
271
|
+
reg.fit(X, y)
|
|
272
|
+
preds = reg.predict(X)
|
|
273
|
+
self.assertEqual(preds.shape, (2, 1))
|
|
274
|
+
|
|
275
|
+
|
|
188
276
|
@unittest.skipUnless(TORCH_AVAILABLE, 'requires torch and skorch')
|
|
189
277
|
class TestSetSeed(unittest.TestCase):
|
|
190
278
|
|
|
@@ -386,5 +474,73 @@ class TestMultiTaskNN(unittest.TestCase):
|
|
|
386
474
|
reg.set_architecture(4, 1)
|
|
387
475
|
|
|
388
476
|
|
|
477
|
+
@unittest.skipUnless(TORCH_AVAILABLE, 'requires torch and skorch')
|
|
478
|
+
class TestMultiTaskMaskedLoss(unittest.TestCase):
|
|
479
|
+
"""Multi-task data is rarely dense - NaN targets must be excluded from the loss, not crash it."""
|
|
480
|
+
|
|
481
|
+
def test_criterion_reduction_is_none(self):
|
|
482
|
+
# _MaskedMultiTaskLoss.get_loss needs an unreduced, per-element loss to mask.
|
|
483
|
+
with tempfile.TemporaryDirectory() as d:
|
|
484
|
+
reg = MultiTaskNNRegressor(d, epochs=2)
|
|
485
|
+
clf = MultiTaskNNClassifier(d, epochs=2)
|
|
486
|
+
self.assertEqual(reg.criterion__reduction, 'none')
|
|
487
|
+
self.assertEqual(clf.criterion__reduction, 'none')
|
|
488
|
+
|
|
489
|
+
def test_regressor_loss_ignores_nan_targets(self):
|
|
490
|
+
with tempfile.TemporaryDirectory() as d:
|
|
491
|
+
reg = MultiTaskNNRegressor(d, epochs=1)
|
|
492
|
+
reg.set_architecture(4, 3)
|
|
493
|
+
reg.initialize()
|
|
494
|
+
y_pred = nn_mod.torch.tensor([[1.0, 2.0, 3.0]])
|
|
495
|
+
y_true = nn_mod.torch.tensor([[1.0, float('nan'), 5.0]])
|
|
496
|
+
loss = reg.get_loss(y_pred, y_true.numpy())
|
|
497
|
+
# column 1 (NaN) excluded: mean((1-1)**2, (3-5)**2) = 2.0
|
|
498
|
+
self.assertAlmostEqual(loss.item(), 2.0, places=5)
|
|
499
|
+
|
|
500
|
+
def test_all_nan_batch_does_not_raise_or_produce_nan_loss(self):
|
|
501
|
+
with tempfile.TemporaryDirectory() as d:
|
|
502
|
+
reg = MultiTaskNNRegressor(d, epochs=1)
|
|
503
|
+
reg.set_architecture(4, 3)
|
|
504
|
+
reg.initialize()
|
|
505
|
+
y_pred = nn_mod.torch.zeros((2, 3))
|
|
506
|
+
y_true = nn_mod.torch.full((2, 3), float('nan'))
|
|
507
|
+
loss = reg.get_loss(y_pred, y_true.numpy())
|
|
508
|
+
self.assertEqual(loss.item(), 0.0)
|
|
509
|
+
|
|
510
|
+
def test_regressor_fits_with_sparse_targets(self):
|
|
511
|
+
rng = _rng()
|
|
512
|
+
X = pd.DataFrame(rng.random((40, 4)))
|
|
513
|
+
y = pd.DataFrame(rng.random((40, 3)))
|
|
514
|
+
y.iloc[::2, 1] = np.nan # half of task 1's labels missing
|
|
515
|
+
X_valid = pd.DataFrame(rng.random((10, 4)))
|
|
516
|
+
y_valid = pd.DataFrame(rng.random((10, 3)))
|
|
517
|
+
y_valid.iloc[::3, 0] = np.nan
|
|
518
|
+
with tempfile.TemporaryDirectory() as d:
|
|
519
|
+
reg = MultiTaskNNRegressor(d, epochs=2, early_stop=2, lr=0.01, hidden_layers=[8, 4])
|
|
520
|
+
reg.set_architecture(4, 3)
|
|
521
|
+
reg.set_validation(X_valid, y_valid)
|
|
522
|
+
reg.fit(X, y)
|
|
523
|
+
preds = reg.predict(X)
|
|
524
|
+
self.assertEqual(preds.shape, (40, 3))
|
|
525
|
+
self.assertFalse(np.isnan(preds).any())
|
|
526
|
+
|
|
527
|
+
def test_classifier_fits_with_sparse_targets(self):
|
|
528
|
+
rng = _rng()
|
|
529
|
+
X = pd.DataFrame(rng.random((40, 4)))
|
|
530
|
+
y = pd.DataFrame(rng.integers(0, 2, (40, 3)).astype(float))
|
|
531
|
+
y.iloc[::2, 1] = np.nan
|
|
532
|
+
X_valid = pd.DataFrame(rng.random((10, 4)))
|
|
533
|
+
y_valid = pd.DataFrame(rng.integers(0, 2, (10, 3)).astype(float))
|
|
534
|
+
y_valid.iloc[::3, 0] = np.nan
|
|
535
|
+
with tempfile.TemporaryDirectory() as d:
|
|
536
|
+
clf = MultiTaskNNClassifier(d, epochs=2, early_stop=2, lr=0.01, hidden_layers=[8, 4])
|
|
537
|
+
clf.set_architecture(4, 3)
|
|
538
|
+
clf.set_validation(X_valid, y_valid)
|
|
539
|
+
clf.fit(X, y)
|
|
540
|
+
preds = clf.predict(X)
|
|
541
|
+
self.assertEqual(preds.shape, (40, 3))
|
|
542
|
+
self.assertTrue(set(np.unique(preds)).issubset({0.0, 1.0}))
|
|
543
|
+
|
|
544
|
+
|
|
389
545
|
if __name__ == '__main__':
|
|
390
546
|
unittest.main()
|
|
@@ -140,7 +140,8 @@ class TestPapyrusDataset(unittest.TestCase):
|
|
|
140
140
|
['Adenosine receptor'],
|
|
141
141
|
)
|
|
142
142
|
self.assertEqual(oop_data_agg['type_IC50'].cast(pl.Int64).unique().to_list(), [1])
|
|
143
|
-
oop_data_proteins = (PapyrusDataset.from_dataframe(oop_data_agg, self.stereo, self.version, self.plusplus
|
|
143
|
+
oop_data_proteins = (PapyrusDataset.from_dataframe(oop_data_agg, self.stereo, self.version, self.plusplus,
|
|
144
|
+
source_path=SOURCE_PATH)
|
|
144
145
|
.proteins(progress=True)
|
|
145
146
|
.to_dataframe(False))
|
|
146
147
|
self.assertEqual(len(oop_data_agg['accession'].unique()), len(oop_data_proteins))
|
|
@@ -193,7 +194,8 @@ class TestPapyrusDataset(unittest.TestCase):
|
|
|
193
194
|
)
|
|
194
195
|
for accession in oop_data_agg['accession'].unique():
|
|
195
196
|
self.assertNotIn(accession, ['P13500', 'P13501'])
|
|
196
|
-
oop_data_proteins = (PapyrusDataset.from_dataframe(oop_data_agg, self.stereo, self.version, self.plusplus
|
|
197
|
+
oop_data_proteins = (PapyrusDataset.from_dataframe(oop_data_agg, self.stereo, self.version, self.plusplus,
|
|
198
|
+
source_path=SOURCE_PATH)
|
|
197
199
|
.proteins(progress=True)
|
|
198
200
|
.to_dataframe(False))
|
|
199
201
|
self.assertEqual(oop_data_proteins['Organism'].unique().to_list(), ['Mus musculus (Mouse)'])
|
|
@@ -545,6 +545,76 @@ class TestPapyrusDatasetDiskMargin(unittest.TestCase):
|
|
|
545
545
|
self.assertEqual(mock_download.call_args.kwargs['disk_margin'], 0.25)
|
|
546
546
|
|
|
547
547
|
|
|
548
|
+
class TestFromDataframeMissingProteinData(unittest.TestCase):
|
|
549
|
+
"""from_dataframe must raise on missing protein data unless download_if_missing=True."""
|
|
550
|
+
|
|
551
|
+
def _df(self):
|
|
552
|
+
return pl.DataFrame({'connectivity': []})
|
|
553
|
+
|
|
554
|
+
def test_raises_by_default_on_missing_data(self):
|
|
555
|
+
with patch('src.papyrus_scripts.oop.reader.read_protein_set', side_effect=FileNotFoundError):
|
|
556
|
+
with self.assertRaises(FileNotFoundError):
|
|
557
|
+
PapyrusDataset.from_dataframe(df=self._df(), is3d=False, version='2022.04.2')
|
|
558
|
+
|
|
559
|
+
def test_does_not_download_by_default(self):
|
|
560
|
+
with (
|
|
561
|
+
patch('src.papyrus_scripts.oop.reader.read_protein_set', side_effect=FileNotFoundError),
|
|
562
|
+
patch('src.papyrus_scripts.oop.download.download_papyrus') as mock_download,
|
|
563
|
+
):
|
|
564
|
+
with self.assertRaises(FileNotFoundError):
|
|
565
|
+
PapyrusDataset.from_dataframe(df=self._df(), is3d=False, version='2022.04.2')
|
|
566
|
+
mock_download.assert_not_called()
|
|
567
|
+
|
|
568
|
+
def test_download_if_missing_downloads_and_retries(self):
|
|
569
|
+
with (
|
|
570
|
+
patch(
|
|
571
|
+
'src.papyrus_scripts.oop.reader.read_protein_set',
|
|
572
|
+
side_effect=[FileNotFoundError, pl.DataFrame()],
|
|
573
|
+
),
|
|
574
|
+
patch('src.papyrus_scripts.oop.download.download_papyrus') as mock_download,
|
|
575
|
+
):
|
|
576
|
+
dataset = PapyrusDataset.from_dataframe(
|
|
577
|
+
df=self._df(), is3d=False, version='2022.04.2', download_if_missing=True,
|
|
578
|
+
)
|
|
579
|
+
mock_download.assert_called_once()
|
|
580
|
+
self.assertIsInstance(dataset.papyrus_protein_data, pl.DataFrame)
|
|
581
|
+
|
|
582
|
+
def test_download_if_missing_uses_pystow_path_key_and_forwards_params(self):
|
|
583
|
+
with (
|
|
584
|
+
patch(
|
|
585
|
+
'src.papyrus_scripts.oop.reader.read_protein_set',
|
|
586
|
+
side_effect=[FileNotFoundError, pl.DataFrame()],
|
|
587
|
+
),
|
|
588
|
+
patch('src.papyrus_scripts.oop.download.download_papyrus') as mock_download,
|
|
589
|
+
):
|
|
590
|
+
PapyrusDataset.from_dataframe(
|
|
591
|
+
df=self._df(), is3d=True, version='2022.04.2', plusplus=False,
|
|
592
|
+
source_path='/tmp/somewhere', download_progress=False, # noqa: S108
|
|
593
|
+
keep_original_files=True, disk_margin=0.25, download_if_missing=True,
|
|
594
|
+
)
|
|
595
|
+
kwargs = mock_download.call_args.kwargs
|
|
596
|
+
self.assertEqual(kwargs['version'], '2022.04.2')
|
|
597
|
+
self.assertEqual(kwargs['outdir'], '/tmp/somewhere') # noqa: S108
|
|
598
|
+
self.assertTrue(kwargs['stereo'])
|
|
599
|
+
self.assertFalse(kwargs['nostereo'])
|
|
600
|
+
self.assertFalse(kwargs['only_pp'])
|
|
601
|
+
self.assertFalse(kwargs['structures'])
|
|
602
|
+
self.assertIsNone(kwargs['descriptors'])
|
|
603
|
+
self.assertFalse(kwargs['progress'])
|
|
604
|
+
self.assertTrue(kwargs['keep_xz'])
|
|
605
|
+
self.assertEqual(kwargs['disk_margin'], 0.25)
|
|
606
|
+
|
|
607
|
+
def test_does_not_download_when_data_is_present(self):
|
|
608
|
+
with (
|
|
609
|
+
patch('src.papyrus_scripts.oop.reader.read_protein_set', return_value=pl.DataFrame()),
|
|
610
|
+
patch('src.papyrus_scripts.oop.download.download_papyrus') as mock_download,
|
|
611
|
+
):
|
|
612
|
+
PapyrusDataset.from_dataframe(
|
|
613
|
+
df=self._df(), is3d=False, version='2022.04.2', download_if_missing=True,
|
|
614
|
+
)
|
|
615
|
+
mock_download.assert_not_called()
|
|
616
|
+
|
|
617
|
+
|
|
548
618
|
class TestProteinDescriptorsCustomPath(unittest.TestCase):
|
|
549
619
|
"""protein_descriptors('custom', ...) must accept a custom_descriptor_path
|
|
550
620
|
and forward it as source_path, without attempting the Papyrus
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|