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.
- core/README.md +14 -0
- core/__init__.py +23 -0
- core/botorch_models.py +305 -0
- core/data_loader.py +175 -0
- core/experiment.py +491 -0
- core/initial_selection_strategies.py +669 -0
- core/metrics_calculator.py +170 -0
- core/predictor_trainer.py +243 -0
- core/query_strategies.py +517 -0
- core/round_tracker.py +168 -0
- core/stable_rbf_kernel.py +34 -0
- core/stopping.py +201 -0
- core/uncertainty.py +135 -0
- deepdraw/__init__.py +15 -0
- deepdraw/cli.py +321 -0
- deepdraw/conf/__init__.py +1 -0
- deepdraw/conf/initial_selection_strategy/core_set.yaml +4 -0
- deepdraw/conf/initial_selection_strategy/density_core_set.yaml +6 -0
- deepdraw/conf/initial_selection_strategy/kmeans.yaml +3 -0
- deepdraw/conf/initial_selection_strategy/probcover.yaml +13 -0
- deepdraw/conf/initial_selection_strategy/probcover_cosine.yaml +13 -0
- deepdraw/conf/initial_selection_strategy/random.yaml +3 -0
- deepdraw/conf/initial_selection_strategy/typiclust.yaml +9 -0
- deepdraw/conf/predictor/botorch_gp.yaml +7 -0
- deepdraw/conf/predictor/botorch_linear_gp.yaml +7 -0
- deepdraw/conf/predictor/botorch_rf_ensemble.yaml +11 -0
- deepdraw/conf/predictor/gradboost_regressor.yaml +3 -0
- deepdraw/conf/query_strategy/botorch_gibbon.yaml +7 -0
- deepdraw/conf/query_strategy/botorch_log_ei.yaml +4 -0
- deepdraw/conf/query_strategy/botorch_log_pi.yaml +4 -0
- deepdraw/conf/query_strategy/botorch_mes.yaml +7 -0
- deepdraw/conf/query_strategy/botorch_pes.yaml +7 -0
- deepdraw/conf/query_strategy/botorch_qlog_ei.yaml +4 -0
- deepdraw/conf/query_strategy/botorch_qlog_nei.yaml +4 -0
- deepdraw/conf/query_strategy/botorch_ucb.yaml +5 -0
- deepdraw/conf/query_strategy/predstdhybrid.yaml +3 -0
- deepdraw/conf/query_strategy/random.yaml +2 -0
- deepdraw/conf/query_strategy/topk.yaml +1 -0
- deepdraw/conf/transforms/log_standardize.yaml +12 -0
- deepdraw/conf/transforms/none.yaml +2 -0
- deepdraw/conf/transforms/standardize.yaml +5 -0
- deepdraw/embeddings/__init__.py +5 -0
- deepdraw/embeddings/alphagenome.py +726 -0
- deepdraw/embeddings/pca.py +431 -0
- deepdraw/workflow.py +1069 -0
- deepdraw_opt-0.1.1.dist-info/METADATA +268 -0
- deepdraw_opt-0.1.1.dist-info/RECORD +50 -0
- deepdraw_opt-0.1.1.dist-info/WHEEL +4 -0
- deepdraw_opt-0.1.1.dist-info/entry_points.txt +2 -0
- 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)
|