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.
Files changed (46) hide show
  1. {papyrus_scripts-3.0.1/src/papyrus_scripts.egg-info → papyrus_scripts-3.0.2}/PKG-INFO +7 -1
  2. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/README.md +6 -0
  3. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/__init__.py +1 -1
  4. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/fingerprint.py +22 -1
  5. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/modelling.py +9 -2
  6. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/neuralnet.py +27 -9
  7. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/oop.py +24 -3
  8. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/preprocess.py +2 -5
  9. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/subsim_search.py +14 -11
  10. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/IO.py +12 -2
  11. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2/src/papyrus_scripts.egg-info}/PKG-INFO +7 -1
  12. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_modelling.py +161 -0
  13. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_neuralnet.py +156 -0
  14. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_oop.py +4 -2
  15. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_oop_unit.py +70 -0
  16. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/LICENSE +0 -0
  17. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/pyproject.toml +0 -0
  18. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/setup.cfg +0 -0
  19. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/__main__.py +0 -0
  20. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/cli.py +0 -0
  21. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/download.py +0 -0
  22. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/matchRCSB.py +0 -0
  23. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/reader.py +0 -0
  24. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/UniprotMatch.py +0 -0
  25. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/__init__.py +0 -0
  26. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/aliases.json +0 -0
  27. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/links.json +0 -0
  28. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/mol_reader.py +0 -0
  29. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/SOURCES.txt +0 -0
  30. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/dependency_links.txt +0 -0
  31. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/entry_points.txt +0 -0
  32. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/requires.txt +0 -0
  33. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/top_level.txt +0 -0
  34. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_cli.py +0 -0
  35. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_download.py +0 -0
  36. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_fingerprint.py +0 -0
  37. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_io.py +0 -0
  38. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_matchRCSB.py +0 -0
  39. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_modelling_real_data.py +0 -0
  40. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_mol_reader.py +0 -0
  41. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_preprocess.py +0 -0
  42. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_reader.py +0 -0
  43. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_reader_offline.py +0 -0
  44. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_reader_real_data.py +0 -0
  45. {papyrus_scripts-3.0.1 → papyrus_scripts-3.0.2}/tests/test_subsim_search.py +0 -0
  46. {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.1
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
 
@@ -35,4 +35,4 @@ from .utils import IO, UniprotMatch
35
35
  from .utils.IO import PapyrusVersion
36
36
  from .utils.mol_reader import MolSupplier
37
37
 
38
- __version__ = '3.0.1'
38
+ __version__ = '3.0.2'
@@ -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
- from FPSim2.FPSim2lib.utils import BitStrToIntList, PyPopcount
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
- test_set = data[data['Year'] >= split_year]
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
- groups = cluster_method.fit_predict(data.drop(columns=features_to_ignore))
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 MultiTaskNNClassifier(BaseNN, skorch.NeuralNetClassifier if HAS_TORCH else object): # type: ignore[misc]
319
- """Neural Network classifier to predict multiple (independent, binary) endpoints."""
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
- # Raw logits + BCEWithLogitsLoss (see SingleTaskNNClassifier for why);
324
- # probabilities are produced post-hoc via predict_nonlinearity.
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, skorch.NeuralNetRegressor if HAS_TORCH else object): # type: ignore[misc]
354
- """Neural Network regressor to predict multiple endpoints."""
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
- super().__init__(*args, criterion=nn.MSELoss, **kwargs)
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
- dataset.papyrus_protein_data = reader.read_protein_set(
454
- source_path=source_path, version=pv,
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
- # 'horizontal_extend' only exists in very recent polars releases (absent
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
- from FPSim2.base import BaseEngine # pragma: no cover
40
- from FPSim2.FPSim2 import FPSim2Engine
41
- from FPSim2.io.backends.base import BaseStorageBackend # pragma: no cover
42
- from FPSim2.io.backends.pytables import ( # pragma: no cover
43
- BATCH_WRITE_SIZE,
44
- calc_popcnt_bins_pytables,
45
- create_schema,
46
- )
47
- from FPSim2.io.chem import load_molecule # pragma: no cover
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
- from FPSim2.FPSim2Cuda import FPSim2CudaEngine # pragma: no cover
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
- return {
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
- if target not in _NULLABLE_INT_DTYPES or col in forced_float_cols:
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.1
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