papyrus-scripts 3.0.0__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.0/src/papyrus_scripts.egg-info → papyrus_scripts-3.0.2}/PKG-INFO +7 -1
  2. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/README.md +6 -0
  3. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/__init__.py +1 -1
  4. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/download.py +49 -11
  5. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/fingerprint.py +22 -1
  6. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/modelling.py +9 -2
  7. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/neuralnet.py +27 -9
  8. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/oop.py +24 -3
  9. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/preprocess.py +3 -1
  10. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/subsim_search.py +21 -13
  11. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/IO.py +12 -2
  12. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/links.json +1524 -1524
  13. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2/src/papyrus_scripts.egg-info}/PKG-INFO +7 -1
  14. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_download.py +94 -0
  15. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_modelling.py +161 -0
  16. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_neuralnet.py +162 -1
  17. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_oop.py +4 -2
  18. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_oop_unit.py +70 -0
  19. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/LICENSE +0 -0
  20. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/pyproject.toml +0 -0
  21. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/setup.cfg +0 -0
  22. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/__main__.py +0 -0
  23. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/cli.py +0 -0
  24. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/matchRCSB.py +0 -0
  25. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/reader.py +0 -0
  26. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/UniprotMatch.py +0 -0
  27. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/__init__.py +0 -0
  28. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/aliases.json +0 -0
  29. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts/utils/mol_reader.py +0 -0
  30. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/SOURCES.txt +0 -0
  31. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/dependency_links.txt +0 -0
  32. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/entry_points.txt +0 -0
  33. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/requires.txt +0 -0
  34. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/src/papyrus_scripts.egg-info/top_level.txt +0 -0
  35. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_cli.py +0 -0
  36. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_fingerprint.py +0 -0
  37. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_io.py +0 -0
  38. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_matchRCSB.py +0 -0
  39. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_modelling_real_data.py +0 -0
  40. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_mol_reader.py +0 -0
  41. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_preprocess.py +0 -0
  42. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_reader.py +0 -0
  43. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_reader_offline.py +0 -0
  44. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_reader_real_data.py +0 -0
  45. {papyrus_scripts-3.0.0 → papyrus_scripts-3.0.2}/tests/test_subsim_search.py +0 -0
  46. {papyrus_scripts-3.0.0 → 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.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
 
@@ -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.0'
38
+ __version__ = '3.0.2'
@@ -83,17 +83,29 @@ _SIZE_KEY_BY_FTYPE = {
83
83
  '3D_structures': 'structures_3D',
84
84
  }
85
85
 
86
- def _size_for_ftype(sizes: dict, ftype: str) -> int | None:
86
+ #: data_size.json omits 'prodec' on every 2022.11.x release (.1-.4), even
87
+ #: though the file itself is published. All four share one sha256; row
88
+ #: count independently verified via ``xz -dc <file> | wc -l``.
89
+ _ROW_COUNT_OVERRIDE_BY_SHA256: dict[str, int] = {
90
+ '3211a62f18ccb7ccc13f885374c1462efeb83ab0e98ed62d2645723f7dc9f1a1': 7505,
91
+ }
92
+
93
+
94
+ def _size_for_ftype(sizes: dict, ftype: str, sha256: str | None = None) -> int | None:
87
95
  """Look up *ftype*'s row count in a parsed data_size.json, or ``None`` if absent.
88
96
 
89
97
  Releases up to 2022.11.4 key papyrus++ as ``"papyrus++"``; 2024.09.1+
90
98
  use ``"papyrus_++"`` (``_SIZE_KEY_BY_FTYPE['papyrus++']``) - try both.
99
+
100
+ :param sha256: fallback key into :data:`_ROW_COUNT_OVERRIDE_BY_SHA256`.
91
101
  """
92
102
  key = _SIZE_KEY_BY_FTYPE.get(ftype)
93
103
  if key in sizes:
94
104
  return sizes[key]
95
105
  if ftype == 'papyrus++' and 'papyrus++' in sizes:
96
106
  return sizes['papyrus++']
107
+ if sha256 is not None and sha256 in _ROW_COUNT_OVERRIDE_BY_SHA256:
108
+ return _ROW_COUNT_OVERRIDE_BY_SHA256[sha256]
97
109
  return None
98
110
 
99
111
 
@@ -807,7 +819,11 @@ def download_papyrus(outdir: str | Path | None = None,
807
819
  _update_current_file_description()
808
820
 
809
821
  def _wait_for_converter() -> None:
810
- """Block until the converter process exits, draining progress messages meanwhile."""
822
+ """Block until the converter process exits, draining progress messages meanwhile.
823
+
824
+ Does not close converting_pbar - callers do that via
825
+ _close_converting_pbar, once success/failure is known.
826
+ """
811
827
  # Only ever called once converter_process has been started (see call sites).
812
828
  if converter_process is None: # noqa: B023
813
829
  raise RuntimeError('converter_process not started')
@@ -815,8 +831,27 @@ def download_papyrus(outdir: str | Path | None = None,
815
831
  _drain_progress_queue()
816
832
  converter_process.join(timeout=0.1) # noqa: B023
817
833
  _drain_progress_queue()
818
- if converting_pbar is not None: # noqa: B023
819
- converting_pbar.close() # noqa: B023
834
+
835
+ def _close_converting_pbar(success: bool) -> None:
836
+ """Close converting_pbar, topping it up to 100% first on success.
837
+
838
+ total is a naive line count that can exceed the true row count
839
+ (see convert_xz_to_parquet's docstring), so n often lands just
840
+ under total even on success - tqdm.notebook then marks the bar
841
+ 'danger' (red) as if it had failed. Topping up avoids that;
842
+ skipped on failure, where n < total is a real signal.
843
+ """
844
+ if converting_pbar is None: # noqa: B023
845
+ return
846
+ if (
847
+ success # noqa: B023
848
+ and isinstance(converting_pbar.total, (int, float)) # noqa: B023
849
+ and isinstance(converting_pbar.n, (int, float)) # noqa: B023
850
+ ):
851
+ shortfall = converting_pbar.total - converting_pbar.n # noqa: B023
852
+ if shortfall > 0:
853
+ converting_pbar.update(shortfall) # noqa: B023
854
+ converting_pbar.close() # noqa: B023
820
855
 
821
856
  def _enqueue(item) -> None:
822
857
  """Put *item* on task_queue, staying responsive while backpressured.
@@ -850,15 +885,16 @@ def download_papyrus(outdir: str | Path | None = None,
850
885
  _drain_progress_queue()
851
886
 
852
887
  if not keep_xz:
853
- # ftype per file still needing conversion; used once data_size.json
854
- # is read to compute converting_pbar's total row count.
855
- to_convert_ftypes: list[str] = []
888
+ # (ftype, sha256) per file still needing conversion, for
889
+ # converting_pbar's total - sha256 lets _size_for_ftype fall
890
+ # back to _ROW_COUNT_OVERRIDE_BY_SHA256 if needed.
891
+ to_convert_ftypes: list[tuple[str, str]] = []
856
892
  for ftype in ordered_ftypes:
857
893
  for entry in _iter_entries(version_files[ftype]):
858
894
  fpath = _file_path(papyrus_version_root, ftype, entry['name'])
859
895
  parquet_path = _parquet_sibling(fpath)
860
896
  if parquet_path is not None and not parquet_path.is_file():
861
- to_convert_ftypes.append(ftype)
897
+ to_convert_ftypes.append((ftype, entry['sha256']))
862
898
 
863
899
  task_queue = mp.Queue(maxsize=1)
864
900
  error_queue = mp.Queue()
@@ -972,8 +1008,8 @@ def download_papyrus(outdir: str | Path | None = None,
972
1008
  # unless any is unknown (avoid understating it).
973
1009
  if progress and converting_pbar is not None and to_convert_ftypes:
974
1010
  per_file_rows = [
975
- _size_for_ftype(sizes, ft)
976
- for ft in to_convert_ftypes
1011
+ _size_for_ftype(sizes, ft, sha)
1012
+ for ft, sha in to_convert_ftypes
977
1013
  ]
978
1014
  if all(n is not None for n in per_file_rows):
979
1015
  # reset() syncs the widget's max too, unlike plain `.total =`.
@@ -985,7 +1021,7 @@ def download_papyrus(outdir: str | Path | None = None,
985
1021
  'parquet_path': parquet_path,
986
1022
  'schema_overrides': schemas.get(_SCHEMA_KEY_BY_FTYPE.get(ftype)),
987
1023
  'null_values': _NULL_VALUES_BY_FTYPE.get(ftype),
988
- 'total_rows': _size_for_ftype(sizes, ftype),
1024
+ 'total_rows': _size_for_ftype(sizes, ftype, dhash),
989
1025
  # ftype (e.g. 'papyrus++', '2D_mold2') rather
990
1026
  # than fpath.name: the real filenames (e.g.
991
1027
  # '05.6++_combined_set_without_stereochemistry
@@ -1012,6 +1048,7 @@ def download_papyrus(outdir: str | Path | None = None,
1012
1048
  raise RuntimeError('task_queue not created') from None
1013
1049
  _enqueue(_CONVERSION_DONE)
1014
1050
  _wait_for_converter()
1051
+ _close_converting_pbar(success=False)
1015
1052
  if progress:
1016
1053
  pbar.close()
1017
1054
  raise
@@ -1028,6 +1065,7 @@ def download_papyrus(outdir: str | Path | None = None,
1028
1065
  _enqueue(_CONVERSION_DONE)
1029
1066
  _wait_for_converter()
1030
1067
  error = error_queue.get()
1068
+ _close_converting_pbar(success=error is None)
1031
1069
  if error is not None:
1032
1070
  if progress:
1033
1071
  pbar.close()
@@ -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,
@@ -290,7 +290,9 @@ def process_groups(
290
290
 
291
291
  if has_pchembl:
292
292
  pv = pl.col('pchembl_value')
293
- pv_num = pv.cast(pl.Float64)
293
+ # Raw values can carry padding (e.g. " 6.700") - strip before the
294
+ # strict numeric cast, which otherwise rejects it.
295
+ pv_num = pv.str.strip_chars().cast(pl.Float64)
294
296
  median = pv_num.median()
295
297
  n = pv_num.drop_nulls().len()
296
298
  std = pv_num.std()
@@ -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,16 +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.FPSim2Cuda import FPSim2CudaEngine # pragma: no cover
42
- from FPSim2.io.backends.base import BaseStorageBackend # pragma: no cover
43
- from FPSim2.io.backends.pytables import ( # pragma: no cover
44
- BATCH_WRITE_SIZE,
45
- calc_popcnt_bins_pytables,
46
- create_schema,
47
- )
48
- 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
49
51
  HAS_FPSIM2 = True # pragma: no cover
50
52
  except ImportError:
51
53
  HAS_FPSIM2 = False
@@ -60,12 +62,18 @@ except ImportError:
60
62
 
61
63
  class FPSim2Engine: # type: ignore[no-redef]
62
64
  """Stub for FPSim2.FPSim2.FPSim2Engine when FPSim2 is absent."""
65
+ BATCH_WRITE_SIZE = 32_000 # FPSim2's own default; only used for queue sizing here
63
66
 
67
+ try:
68
+ # FPSim2.FPSim2Cuda unconditionally imports cupy, so this is kept
69
+ # separate from HAS_FPSIM2 above: a missing GPU stack must not disable
70
+ # CPU-only search.
71
+ with _suppress_missing_cuda_path_warning():
72
+ from FPSim2.FPSim2Cuda import FPSim2CudaEngine # pragma: no cover
73
+ except ImportError:
64
74
  class FPSim2CudaEngine: # type: ignore[no-redef]
65
- """Stub for FPSim2.FPSim2Cuda.FPSim2CudaEngine when FPSim2 is absent."""
66
- BATCH_WRITE_SIZE = 32_000 # FPSim2's own default; only used for queue sizing here
75
+ """Stub for FPSim2.FPSim2Cuda.FPSim2CudaEngine when cupy is absent."""
67
76
 
68
- from .fingerprint import Fingerprint, MorganFingerprint, get_fp_from_name
69
77
  from .utils.IO import PapyrusVersion, _prefer_parquet, _set_root_folder, get_num_rows_in_file, locate_file
70
78
  from .utils.mol_reader import MolSupplier
71
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():