deepdraw-opt 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. core/README.md +14 -0
  2. core/__init__.py +23 -0
  3. core/botorch_models.py +305 -0
  4. core/data_loader.py +175 -0
  5. core/experiment.py +491 -0
  6. core/initial_selection_strategies.py +669 -0
  7. core/metrics_calculator.py +170 -0
  8. core/predictor_trainer.py +243 -0
  9. core/query_strategies.py +517 -0
  10. core/round_tracker.py +168 -0
  11. core/stable_rbf_kernel.py +34 -0
  12. core/stopping.py +201 -0
  13. core/uncertainty.py +135 -0
  14. deepdraw/__init__.py +15 -0
  15. deepdraw/cli.py +321 -0
  16. deepdraw/conf/__init__.py +1 -0
  17. deepdraw/conf/initial_selection_strategy/core_set.yaml +4 -0
  18. deepdraw/conf/initial_selection_strategy/density_core_set.yaml +6 -0
  19. deepdraw/conf/initial_selection_strategy/kmeans.yaml +3 -0
  20. deepdraw/conf/initial_selection_strategy/probcover.yaml +13 -0
  21. deepdraw/conf/initial_selection_strategy/probcover_cosine.yaml +13 -0
  22. deepdraw/conf/initial_selection_strategy/random.yaml +3 -0
  23. deepdraw/conf/initial_selection_strategy/typiclust.yaml +9 -0
  24. deepdraw/conf/predictor/botorch_gp.yaml +7 -0
  25. deepdraw/conf/predictor/botorch_linear_gp.yaml +7 -0
  26. deepdraw/conf/predictor/botorch_rf_ensemble.yaml +11 -0
  27. deepdraw/conf/predictor/gradboost_regressor.yaml +3 -0
  28. deepdraw/conf/query_strategy/botorch_gibbon.yaml +7 -0
  29. deepdraw/conf/query_strategy/botorch_log_ei.yaml +4 -0
  30. deepdraw/conf/query_strategy/botorch_log_pi.yaml +4 -0
  31. deepdraw/conf/query_strategy/botorch_mes.yaml +7 -0
  32. deepdraw/conf/query_strategy/botorch_pes.yaml +7 -0
  33. deepdraw/conf/query_strategy/botorch_qlog_ei.yaml +4 -0
  34. deepdraw/conf/query_strategy/botorch_qlog_nei.yaml +4 -0
  35. deepdraw/conf/query_strategy/botorch_ucb.yaml +5 -0
  36. deepdraw/conf/query_strategy/predstdhybrid.yaml +3 -0
  37. deepdraw/conf/query_strategy/random.yaml +2 -0
  38. deepdraw/conf/query_strategy/topk.yaml +1 -0
  39. deepdraw/conf/transforms/log_standardize.yaml +12 -0
  40. deepdraw/conf/transforms/none.yaml +2 -0
  41. deepdraw/conf/transforms/standardize.yaml +5 -0
  42. deepdraw/embeddings/__init__.py +5 -0
  43. deepdraw/embeddings/alphagenome.py +726 -0
  44. deepdraw/embeddings/pca.py +431 -0
  45. deepdraw/workflow.py +1069 -0
  46. deepdraw_opt-0.1.1.dist-info/METADATA +268 -0
  47. deepdraw_opt-0.1.1.dist-info/RECORD +50 -0
  48. deepdraw_opt-0.1.1.dist-info/WHEEL +4 -0
  49. deepdraw_opt-0.1.1.dist-info/entry_points.txt +2 -0
  50. deepdraw_opt-0.1.1.dist-info/licenses/LICENSE +21 -0
core/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # Core modules
2
+
3
+ These modules provide Deepdraw's active-learning components. For installation and experimental use, start with the [main README](../README.md).
4
+
5
+ - [experiment.py](experiment.py): retrospective active-learning loop.
6
+ - [data_loader.py](data_loader.py): aligned measurement and embedding data for retrospective runs.
7
+ - [initial_selection_strategies.py](initial_selection_strategies.py) and [query_strategies.py](query_strategies.py): initial and subsequent batch selection.
8
+ - [predictor_trainer.py](predictor_trainer.py): model training and feature/target transforms.
9
+ - [botorch_models.py](botorch_models.py) and [stable_rbf_kernel.py](stable_rbf_kernel.py): BoTorch predictors and the RBF kernel.
10
+ - [uncertainty.py](uncertainty.py): predictive uncertainty helpers.
11
+ - [stopping.py](stopping.py): measured-threshold and expected-improvement stopping used by both the experimental and benchmark workflows.
12
+ - [metrics_calculator.py](metrics_calculator.py) and [round_tracker.py](round_tracker.py): retrospective round metrics and run summaries.
13
+
14
+ Optional noise simulation and confirmed-top benchmark stopping are in [benchmarks/noise_simulation.py](../benchmarks/noise_simulation.py).
core/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ Core components for active learning experiments.
3
+
4
+ This package contains the refactored components that break down
5
+ the ActiveLearningExperiment class into smaller, focused classes.
6
+ """
7
+
8
+ from core.data_loader import DataLoader, Dataset
9
+ from core.initial_selection_strategies import InitialSelectionStrategy
10
+ from core.predictor_trainer import PredictorTrainer
11
+ from core.query_strategies import QueryStrategyBase
12
+ from core.round_tracker import RoundTracker
13
+ from core.uncertainty import UncertaintyWrapper
14
+
15
+ __all__ = [
16
+ "DataLoader",
17
+ "Dataset",
18
+ "PredictorTrainer",
19
+ "InitialSelectionStrategy",
20
+ "QueryStrategyBase",
21
+ "RoundTracker",
22
+ "UncertaintyWrapper",
23
+ ]
core/botorch_models.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ BoTorch model wrappers for sklearn-style integration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import torch
11
+ from botorch.fit import fit_gpytorch_mll
12
+ from botorch.models import SingleTaskGP
13
+ from botorch.models.ensemble import EnsembleModel
14
+ from gpytorch.constraints import GreaterThan
15
+ from gpytorch.kernels import LinearKernel, ScaleKernel
16
+ from gpytorch.likelihoods import GaussianLikelihood
17
+ from gpytorch.mlls import ExactMarginalLogLikelihood
18
+ from gpytorch.priors import GammaPrior
19
+ from sklearn.base import RegressorMixin
20
+ from sklearn.ensemble import RandomForestRegressor
21
+
22
+ from core.stable_rbf_kernel import StableRBFKernel
23
+
24
+
25
+ class _RandomForestEnsembleModel(EnsembleModel):
26
+ """
27
+ BoTorch ensemble wrapper around fitted sklearn tree ensembles.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ estimators: list[Any],
33
+ train_X: torch.Tensor,
34
+ weights: torch.Tensor | None = None,
35
+ ) -> None:
36
+ super().__init__(weights=weights)
37
+ self.estimators = list(estimators)
38
+ self.train_inputs = [train_X]
39
+ self._num_outputs = 1
40
+
41
+ def forward(self, X: torch.Tensor) -> torch.Tensor:
42
+ batch_shape = X.shape[:-2]
43
+ q = X.shape[-2]
44
+ flat_X = X.detach().cpu().reshape(-1, X.shape[-1]).numpy()
45
+ member_values: list[torch.Tensor] = []
46
+ for estimator in self.estimators:
47
+ pred = estimator.predict(flat_X)
48
+ pred_tensor = torch.as_tensor(pred, device=X.device, dtype=X.dtype)
49
+ pred_tensor = pred_tensor.reshape(*batch_shape, q, 1)
50
+ member_values.append(pred_tensor)
51
+ return torch.stack(member_values, dim=len(batch_shape))
52
+
53
+
54
+ class _BoTorchBaseRegressor(RegressorMixin):
55
+ """
56
+ Shared utilities for BoTorch sklearn-style wrappers.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ device: str = "cpu",
62
+ dtype: str = "float64",
63
+ ard: bool = False,
64
+ kernel: str = "rbf",
65
+ ) -> None:
66
+ self.device = device
67
+ self.dtype = dtype
68
+ self.ard = ard
69
+ self.kernel = kernel
70
+ self.model_: Any | None = None
71
+
72
+ def get_params(self, deep: bool = True) -> dict[str, Any]:
73
+ return {
74
+ "device": self.device,
75
+ "dtype": self.dtype,
76
+ "ard": self.ard,
77
+ "kernel": self.kernel,
78
+ }
79
+
80
+ def set_params(self, **params: Any):
81
+ for key, value in params.items():
82
+ setattr(self, key, value)
83
+ return self
84
+
85
+ def predict(self, X: np.ndarray) -> np.ndarray:
86
+ mean, _ = self.predict_with_std(X)
87
+ return mean
88
+
89
+ def predict_with_std(self, X: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
90
+ self._check_is_fitted()
91
+ X_tensor = self._to_tensor(X)
92
+ with torch.no_grad():
93
+ posterior = self.model_.posterior(X_tensor)
94
+ mean = posterior.mean.squeeze(-1).cpu().numpy()
95
+ std = posterior.variance.clamp_min(0.0).sqrt().squeeze(-1).cpu().numpy()
96
+ return mean, std
97
+
98
+ def posterior(self, X: np.ndarray) -> Any:
99
+ self._check_is_fitted()
100
+ X_tensor = self._to_tensor(X)
101
+ return self.model_.posterior(X_tensor)
102
+
103
+ def get_botorch_model(self) -> Any:
104
+ self._check_is_fitted()
105
+ return self.model_
106
+
107
+ def _resolve_device(self) -> torch.device:
108
+ if self.device == "auto":
109
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
110
+ return torch.device(self.device)
111
+
112
+ def _resolve_dtype(self) -> torch.dtype:
113
+ if isinstance(self.dtype, torch.dtype):
114
+ return self.dtype
115
+ text = str(self.dtype).lower()
116
+ if text in {"float64", "double", "torch.float64"}:
117
+ return torch.double
118
+ if text in {"float32", "float", "torch.float32"}:
119
+ return torch.float
120
+ raise ValueError(f"Unsupported dtype '{self.dtype}'.")
121
+
122
+ def _build_kernel(self, X_tensor: torch.Tensor):
123
+ ard_dims = X_tensor.shape[-1] if self.ard else None
124
+ kernel_key = str(self.kernel).lower()
125
+ if kernel_key in {"rbf", "se", "squared_exponential"}:
126
+ base_kernel = StableRBFKernel(ard_num_dims=ard_dims)
127
+ return ScaleKernel(base_kernel)
128
+ elif kernel_key in {"linear"}:
129
+ # LinearKernel already has its own variance parameter, so wrapping it
130
+ # again in ScaleKernel adds a redundant output scale and makes fitting
131
+ # less stable on tiny high-dimensional batches.
132
+ return LinearKernel(ard_num_dims=ard_dims)
133
+ else:
134
+ raise ValueError(f"Unsupported kernel '{self.kernel}'. Use rbf or linear.")
135
+
136
+ def _to_tensor(self, array: np.ndarray) -> torch.Tensor:
137
+ device = self._resolve_device()
138
+ dtype = self._resolve_dtype()
139
+ return torch.as_tensor(array, device=device, dtype=dtype)
140
+
141
+ def _check_is_fitted(self) -> None:
142
+ if self.model_ is None:
143
+ raise ValueError(
144
+ f"{self.__class__.__name__}.fit must be called before predict."
145
+ )
146
+
147
+ def _fit_mll(self, mll: ExactMarginalLogLikelihood) -> None:
148
+ fit_gpytorch_mll(mll)
149
+
150
+
151
+ class BoTorchGPRegressor(_BoTorchBaseRegressor):
152
+ """
153
+ Sklearn-compatible wrapper around a BoTorch SingleTaskGP.
154
+ """
155
+
156
+ def __init__(
157
+ self,
158
+ device: str = "cpu",
159
+ dtype: str = "float64",
160
+ ard: bool = False,
161
+ kernel: str = "rbf",
162
+ noise_constraint: float | None = None,
163
+ noise_prior: tuple[float, float] | None = None,
164
+ ) -> None:
165
+ super().__init__(
166
+ device=device,
167
+ dtype=dtype,
168
+ ard=ard,
169
+ kernel=kernel,
170
+ )
171
+ self.noise_constraint = noise_constraint
172
+ self.noise_prior = noise_prior
173
+
174
+ def get_params(self, deep: bool = True) -> dict[str, Any]:
175
+ params = super().get_params(deep=deep)
176
+ params.update(
177
+ {
178
+ "noise_constraint": self.noise_constraint,
179
+ "noise_prior": self.noise_prior,
180
+ }
181
+ )
182
+ return params
183
+
184
+ def fit(
185
+ self,
186
+ X: np.ndarray,
187
+ y: np.ndarray,
188
+ y_var: np.ndarray | None = None,
189
+ ) -> BoTorchGPRegressor:
190
+ X_tensor = self._to_tensor(X)
191
+ y_tensor = self._to_tensor(y).view(-1, 1)
192
+ y_var_tensor = None
193
+ if y_var is not None:
194
+ y_var_tensor = self._to_tensor(y_var).view(-1, 1)
195
+ y_var_tensor = y_var_tensor.clamp_min(1e-12)
196
+
197
+ covar_module = self._build_kernel(X_tensor)
198
+ model_kwargs = {
199
+ "covar_module": covar_module.to(
200
+ device=X_tensor.device,
201
+ dtype=X_tensor.dtype,
202
+ ),
203
+ }
204
+ if y_var_tensor is None:
205
+ likelihood = self._build_likelihood()
206
+ model_kwargs["likelihood"] = likelihood.to(
207
+ device=X_tensor.device,
208
+ dtype=X_tensor.dtype,
209
+ )
210
+ else:
211
+ model_kwargs["train_Yvar"] = y_var_tensor
212
+
213
+ self.model_ = SingleTaskGP(X_tensor, y_tensor, **model_kwargs)
214
+ self.model_.train()
215
+ mll = ExactMarginalLogLikelihood(self.model_.likelihood, self.model_)
216
+ self._fit_mll(mll)
217
+ self.model_.eval()
218
+ return self
219
+
220
+ def _build_likelihood(self) -> GaussianLikelihood:
221
+ constraint = (
222
+ GreaterThan(self.noise_constraint)
223
+ if self.noise_constraint is not None
224
+ else None
225
+ )
226
+ likelihood = (
227
+ GaussianLikelihood(noise_constraint=constraint)
228
+ if constraint is not None
229
+ else GaussianLikelihood()
230
+ )
231
+ if self.noise_prior is not None:
232
+ concentration, rate = self.noise_prior
233
+ likelihood.noise_covar.register_prior(
234
+ "noise_prior",
235
+ GammaPrior(concentration, rate),
236
+ "noise",
237
+ )
238
+ return likelihood
239
+
240
+
241
+ class BoTorchRandomForestEnsembleRegressor(_BoTorchBaseRegressor):
242
+ """
243
+ Sklearn-compatible random forest with a BoTorch ensemble posterior.
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ n_estimators: int = 100,
249
+ max_depth: int | None = None,
250
+ min_samples_split: int = 2,
251
+ min_samples_leaf: int = 1,
252
+ max_features: str | int | float | None = 1.0,
253
+ bootstrap: bool = True,
254
+ random_state: int | None = None,
255
+ n_jobs: int | None = None,
256
+ dtype: str = "float64",
257
+ ) -> None:
258
+ super().__init__(device="cpu", dtype=dtype, ard=False, kernel="rbf")
259
+ self.n_estimators = n_estimators
260
+ self.max_depth = max_depth
261
+ self.min_samples_split = min_samples_split
262
+ self.min_samples_leaf = min_samples_leaf
263
+ self.max_features = max_features
264
+ self.bootstrap = bootstrap
265
+ self.random_state = random_state
266
+ self.n_jobs = n_jobs
267
+ self.estimator_: RandomForestRegressor | None = None
268
+
269
+ def get_params(self, deep: bool = True) -> dict[str, Any]:
270
+ return {
271
+ "n_estimators": self.n_estimators,
272
+ "max_depth": self.max_depth,
273
+ "min_samples_split": self.min_samples_split,
274
+ "min_samples_leaf": self.min_samples_leaf,
275
+ "max_features": self.max_features,
276
+ "bootstrap": self.bootstrap,
277
+ "random_state": self.random_state,
278
+ "n_jobs": self.n_jobs,
279
+ "dtype": self.dtype,
280
+ }
281
+
282
+ def fit(self, X: np.ndarray, y: np.ndarray) -> BoTorchRandomForestEnsembleRegressor:
283
+ X_np = np.asarray(X)
284
+ y_np = np.asarray(y)
285
+ estimator = RandomForestRegressor(
286
+ n_estimators=self.n_estimators,
287
+ max_depth=self.max_depth,
288
+ min_samples_split=self.min_samples_split,
289
+ min_samples_leaf=self.min_samples_leaf,
290
+ max_features=self.max_features,
291
+ bootstrap=self.bootstrap,
292
+ random_state=self.random_state,
293
+ n_jobs=self.n_jobs,
294
+ )
295
+ estimator.fit(X_np, y_np)
296
+ self.estimator_ = estimator
297
+ train_X = torch.as_tensor(
298
+ X_np, device=torch.device("cpu"), dtype=self._resolve_dtype()
299
+ )
300
+ self.model_ = _RandomForestEnsembleModel(
301
+ estimators=list(estimator.estimators_),
302
+ train_X=train_X,
303
+ )
304
+ self.model_.eval()
305
+ return self
core/data_loader.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ Data loading utilities for active learning experiments.
3
+
4
+ This loader pairs an embeddings file with a CSV containing
5
+ labels (and optional sample identifiers), ensuring row alignment between the two.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Dataset:
20
+ """
21
+ Container for loaded dataset with all necessary components.
22
+
23
+ Attributes:
24
+ sample_ids: Stable identifiers aligned with embeddings/labels
25
+ labels: Array of target label values (e.g., expression)
26
+ embeddings: Pre-computed embeddings (required)
27
+ metadata: Optional metadata rows aligned to sample_ids/labels.
28
+ """
29
+
30
+ sample_ids: list[str]
31
+ labels: np.ndarray
32
+ embeddings: np.ndarray
33
+ metadata: pd.DataFrame | None = None
34
+
35
+ def __post_init__(self) -> None:
36
+ """Validate dataset after initialization."""
37
+ if len(self.sample_ids) != len(self.labels):
38
+ raise ValueError(
39
+ f"Sample IDs ({len(self.sample_ids)}) and labels "
40
+ f"({len(self.labels)}) must have the same length"
41
+ )
42
+ if len(self.sample_ids) != len(self.embeddings):
43
+ raise ValueError(
44
+ f"Sample IDs ({len(self.sample_ids)}) and embeddings "
45
+ f"({len(self.embeddings)}) must have the same length"
46
+ )
47
+
48
+
49
+ class DataLoader:
50
+ """
51
+ Load embeddings from NPZ and labels from a paired CSV.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ embeddings_path: str,
57
+ metadata_path: str,
58
+ label_key: str,
59
+ subset_ids_path: str | None = None,
60
+ ) -> None:
61
+ """
62
+ Initialize the data loader.
63
+
64
+ Args:
65
+ embeddings_path: Path to npz file containing embeddings.
66
+ metadata_path: CSV with labels aligned to embeddings.
67
+ label_key: Column in the CSV to use as the training label.
68
+ subset_ids_path: Optional path to a newline-delimited file of sample IDs to keep.
69
+ """
70
+ self.embeddings_path = embeddings_path
71
+ self.metadata_path = metadata_path
72
+ self.label_key = label_key
73
+ self.subset_ids_path = subset_ids_path
74
+ self.dataset: Dataset | None = None
75
+
76
+ def load(self) -> Dataset:
77
+ """
78
+ Load paired embeddings/metadata and return a Dataset.
79
+ """
80
+ logger.info(
81
+ f"Loading embeddings from {self.embeddings_path} "
82
+ f"and metadata from {self.metadata_path}"
83
+ )
84
+
85
+ embeddings, sample_ids = self._load_embeddings()
86
+ embeddings, sample_ids = self._apply_subset_if_needed(embeddings, sample_ids)
87
+ labels, metadata = self._load_metadata(sample_ids)
88
+
89
+ self.dataset = Dataset(
90
+ sample_ids=sample_ids,
91
+ labels=labels,
92
+ embeddings=embeddings,
93
+ metadata=metadata,
94
+ )
95
+
96
+ logger.info(
97
+ f"Loaded dataset with {len(self.dataset.sample_ids)} samples. "
98
+ f"Embeddings shape: {self.dataset.embeddings.shape}"
99
+ )
100
+
101
+ return self.dataset
102
+
103
+ def _load_embeddings(self) -> tuple[np.ndarray, np.ndarray]:
104
+ data = np.load(self.embeddings_path, allow_pickle=True)
105
+ if "embeddings" not in data:
106
+ raise ValueError(
107
+ f"'embeddings' array not found in {self.embeddings_path}. "
108
+ f"Available keys: {list(data.keys())}"
109
+ )
110
+ embeddings = data["embeddings"]
111
+ sample_ids = data["ids"].astype(
112
+ np.int32, copy=False
113
+ ) # sample_ids is row index of csv, so we need to convert it to integer
114
+ return embeddings, sample_ids
115
+
116
+ def _load_metadata(self, sample_ids: np.ndarray) -> tuple[np.ndarray, pd.DataFrame]:
117
+ df = pd.read_csv(self.metadata_path)
118
+ aligned = df.iloc[sample_ids].reset_index(drop=True)
119
+ labels = aligned[self.label_key].to_numpy()
120
+ return labels, aligned
121
+
122
+ def _apply_subset_if_needed(
123
+ self, embeddings: np.ndarray, sample_ids: np.ndarray
124
+ ) -> tuple[np.ndarray, np.ndarray]:
125
+ subset_ids = self._load_subset_ids(sample_ids.dtype)
126
+ if subset_ids is None:
127
+ return embeddings, sample_ids
128
+
129
+ mask = np.isin(sample_ids, subset_ids)
130
+ if not np.any(mask):
131
+ raise ValueError(
132
+ "Subset id filtering removed all samples. "
133
+ "Ensure the subset ids match those stored in the embeddings file."
134
+ )
135
+
136
+ filtered_embeddings = embeddings[mask]
137
+ filtered_sample_ids = sample_ids[mask]
138
+
139
+ missing = set(subset_ids.tolist()) - set(filtered_sample_ids.tolist())
140
+ if missing:
141
+ logger.warning(
142
+ "Subset ids file contained %d ids not present in embeddings; ignoring smallest few: %s",
143
+ len(missing),
144
+ sorted(missing)[:5],
145
+ )
146
+
147
+ logger.info(
148
+ "Subset filtering retained %d / %d samples.",
149
+ len(filtered_sample_ids),
150
+ len(sample_ids),
151
+ )
152
+ return filtered_embeddings, filtered_sample_ids
153
+
154
+ def _load_subset_ids(self, dtype) -> np.ndarray | None:
155
+ if not self.subset_ids_path:
156
+ return None
157
+ subset_path = Path(self.subset_ids_path)
158
+ if not subset_path.exists():
159
+ raise FileNotFoundError(f"Subset ids file {subset_path} does not exist.")
160
+ subset_ids = []
161
+ for line in subset_path.read_text().splitlines():
162
+ text = line.strip()
163
+ if not text:
164
+ continue
165
+ try:
166
+ subset_ids.append(int(text))
167
+ except ValueError as exc:
168
+ raise ValueError(
169
+ f"Invalid sample id '{text}' in subset file {subset_path}"
170
+ ) from exc
171
+ if not subset_ids:
172
+ raise ValueError(
173
+ f"Subset ids file {subset_path} did not contain any sample ids."
174
+ )
175
+ return np.asarray(subset_ids, dtype=dtype)