scembed 0.0.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.
- scembed/__init__.py +12 -0
- scembed/aggregation.py +598 -0
- scembed/check.py +115 -0
- scembed/evaluation.py +285 -0
- scembed/factory.py +77 -0
- scembed/logging.py +25 -0
- scembed/methods/__init__.py +19 -0
- scembed/methods/base.py +407 -0
- scembed/methods/cpu_methods.py +481 -0
- scembed/methods/gpu_methods.py +1033 -0
- scembed/utils.py +212 -0
- scembed-0.0.1.dist-info/METADATA +174 -0
- scembed-0.0.1.dist-info/RECORD +15 -0
- scembed-0.0.1.dist-info/WHEEL +4 -0
- scembed-0.0.1.dist-info/licenses/LICENSE +21 -0
scembed/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Single-cell embedding comparison utilities and evaluation."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .aggregation import scIBAggregator
|
|
6
|
+
from .evaluation import IntegrationEvaluator
|
|
7
|
+
from .factory import get_method_instance
|
|
8
|
+
from .logging import logger
|
|
9
|
+
|
|
10
|
+
__all__ = ["IntegrationEvaluator", "scIBAggregator", "get_method_instance", "logger"]
|
|
11
|
+
|
|
12
|
+
__version__ = version("scembed")
|
scembed/aggregation.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""WandB sweep results aggregation and visualization for scIB benchmarking."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import scanpy as sc
|
|
10
|
+
import wandb
|
|
11
|
+
from scib_metrics.benchmark import BatchCorrection, Benchmarker, BioConservation
|
|
12
|
+
from scib_metrics.benchmark._core import metric_name_cleaner
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
from scembed.logging import logger
|
|
16
|
+
from scembed.utils import _download_artifact_by_run_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class scIBAggregator:
|
|
20
|
+
"""Aggregator for WandB sweep results with scIB metrics visualization.
|
|
21
|
+
|
|
22
|
+
Retrieves runs from a WandB project, organizes them by integration method,
|
|
23
|
+
and provides visualization capabilities using scIB metrics formatting.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
entity
|
|
28
|
+
WandB entity name.
|
|
29
|
+
project
|
|
30
|
+
WandB project name.
|
|
31
|
+
output_dir
|
|
32
|
+
Directory for saving downloaded models and embeddings. If None, creates temporary directory.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, entity: str, project: str, output_dir: str | Path | None = None):
|
|
36
|
+
self.entity = entity
|
|
37
|
+
self.project = project
|
|
38
|
+
|
|
39
|
+
# Setup output directories
|
|
40
|
+
self._temp_dir = None
|
|
41
|
+
if output_dir is None:
|
|
42
|
+
self._temp_dir = TemporaryDirectory()
|
|
43
|
+
output_dir = Path(self._temp_dir.name)
|
|
44
|
+
elif isinstance(output_dir, str):
|
|
45
|
+
output_dir = Path(output_dir)
|
|
46
|
+
|
|
47
|
+
self.output_dir = output_dir
|
|
48
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
# Raw data storage
|
|
51
|
+
self.raw_df: pd.DataFrame | None = None
|
|
52
|
+
|
|
53
|
+
# Processed data organized by method
|
|
54
|
+
self.method_data: dict[str, dict[str, pd.DataFrame | Benchmarker]] = {}
|
|
55
|
+
|
|
56
|
+
# Data quality tracking
|
|
57
|
+
self.n_runs_fetched = 0
|
|
58
|
+
self.n_runs_filtered = 0
|
|
59
|
+
self.missing_config_runs: list[str] = []
|
|
60
|
+
self.missing_metrics_runs: list[str] = []
|
|
61
|
+
|
|
62
|
+
# Available metrics in the data
|
|
63
|
+
self.available_scib_metrics: set[str] = set()
|
|
64
|
+
|
|
65
|
+
# Mapping of metric display names to their types
|
|
66
|
+
self.metric_to_type: dict[str, str] = self._build_metric_type_mapping()
|
|
67
|
+
|
|
68
|
+
# Aggregated results (best run per method)
|
|
69
|
+
self.results: dict[str, pd.DataFrame | Benchmarker] | None = None
|
|
70
|
+
|
|
71
|
+
logger.info("Initialized scIBAggregator for %s/%s", entity, project)
|
|
72
|
+
|
|
73
|
+
def fetch_runs(self) -> None:
|
|
74
|
+
"""Fetch runs from WandB and process into internal storage."""
|
|
75
|
+
logger.info("Fetching runs from %s/%s...", self.entity, self.project)
|
|
76
|
+
|
|
77
|
+
# Reset state for fresh fetch
|
|
78
|
+
self.raw_df = None
|
|
79
|
+
self.method_data = {}
|
|
80
|
+
self.n_runs_fetched = 0
|
|
81
|
+
self.n_runs_filtered = 0
|
|
82
|
+
self.missing_config_runs = []
|
|
83
|
+
self.missing_metrics_runs = []
|
|
84
|
+
self.available_scib_metrics = set()
|
|
85
|
+
|
|
86
|
+
# Initialize WandB API
|
|
87
|
+
api = wandb.Api()
|
|
88
|
+
|
|
89
|
+
# Fetch all runs for the project
|
|
90
|
+
runs = api.runs(f"{self.entity}/{self.project}")
|
|
91
|
+
|
|
92
|
+
# Extract parameter configurations and final results
|
|
93
|
+
data = []
|
|
94
|
+
for run in tqdm(runs, desc="Processing runs"):
|
|
95
|
+
run_data = {
|
|
96
|
+
"run_id": run.id,
|
|
97
|
+
"name": run.name,
|
|
98
|
+
**run.config,
|
|
99
|
+
**dict(run.summary),
|
|
100
|
+
}
|
|
101
|
+
data.append(run_data)
|
|
102
|
+
|
|
103
|
+
# Convert to DataFrame
|
|
104
|
+
self.raw_df = pd.DataFrame(data)
|
|
105
|
+
self.n_runs_fetched = len(self.raw_df)
|
|
106
|
+
|
|
107
|
+
logger.info("Fetched %d runs", self.n_runs_fetched)
|
|
108
|
+
|
|
109
|
+
# Process the data
|
|
110
|
+
self._process_runs()
|
|
111
|
+
|
|
112
|
+
logger.info(
|
|
113
|
+
"Processing complete: %d methods, %d runs filtered out", len(self.method_data), self.n_runs_filtered
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _process_runs(self) -> None:
|
|
117
|
+
"""Process raw runs into method-organized data structures."""
|
|
118
|
+
if self.raw_df is None:
|
|
119
|
+
raise ValueError("No runs fetched. Call fetch_runs() first.")
|
|
120
|
+
|
|
121
|
+
# Filter runs with missing configs
|
|
122
|
+
valid_runs = []
|
|
123
|
+
for idx, row in self.raw_df.iterrows():
|
|
124
|
+
config = row.get("config")
|
|
125
|
+
if config is None or not isinstance(config, dict) or "method" not in config:
|
|
126
|
+
self.missing_config_runs.append(row["run_id"])
|
|
127
|
+
continue
|
|
128
|
+
valid_runs.append(idx)
|
|
129
|
+
|
|
130
|
+
if self.missing_config_runs:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"Filtered out %d runs with missing/invalid configs: %s",
|
|
133
|
+
len(self.missing_config_runs),
|
|
134
|
+
self.missing_config_runs[:5],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
valid_df = self.raw_df.loc[valid_runs].copy()
|
|
138
|
+
|
|
139
|
+
# Extract scIB metrics and validate
|
|
140
|
+
scib_metrics_data = []
|
|
141
|
+
other_logs_data = []
|
|
142
|
+
|
|
143
|
+
# Define aggregate metrics to exclude from individual metrics
|
|
144
|
+
aggregate_metrics = {"Batch correction", "Bio conservation", "Total"}
|
|
145
|
+
|
|
146
|
+
for _, row in valid_df.iterrows():
|
|
147
|
+
config = row["config"]
|
|
148
|
+
method = config["method"]
|
|
149
|
+
|
|
150
|
+
# Extract scIB metrics from the 'scib' column
|
|
151
|
+
scib_data = row.get("scib", {})
|
|
152
|
+
|
|
153
|
+
# Handle various types of scib data (dict, SummarySubDict, etc.)
|
|
154
|
+
if scib_data is None:
|
|
155
|
+
self.missing_metrics_runs.append(row["run_id"])
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# Convert to regular dict if it's a wandb SummarySubDict or similar
|
|
159
|
+
if hasattr(scib_data, "keys") and hasattr(scib_data, "items"):
|
|
160
|
+
scib_data = dict(scib_data)
|
|
161
|
+
|
|
162
|
+
# Now check if it's empty or not a dict
|
|
163
|
+
if not isinstance(scib_data, dict) or len(scib_data) == 0:
|
|
164
|
+
self.missing_metrics_runs.append(row["run_id"])
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Separate actual scIB metrics from aggregates
|
|
168
|
+
actual_scib_metrics = {k: v for k, v in scib_data.items() if k not in aggregate_metrics}
|
|
169
|
+
|
|
170
|
+
# Build other logs entry (everything else from the run)
|
|
171
|
+
other_logs_entry = {
|
|
172
|
+
"run_id": row["run_id"],
|
|
173
|
+
"method": method,
|
|
174
|
+
**{k: v for k, v in row.items() if k not in ["config", "scib", "run_id"]},
|
|
175
|
+
}
|
|
176
|
+
other_logs_data.append(other_logs_entry)
|
|
177
|
+
|
|
178
|
+
# Build scIB metrics entry (only actual metrics, no aggregates)
|
|
179
|
+
metrics_entry = {"run_id": row["run_id"], "method": method, **actual_scib_metrics}
|
|
180
|
+
scib_metrics_data.append(metrics_entry)
|
|
181
|
+
|
|
182
|
+
# Track available metrics (only actual metrics)
|
|
183
|
+
self.available_scib_metrics.update(actual_scib_metrics.keys())
|
|
184
|
+
|
|
185
|
+
if self.missing_metrics_runs:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"Filtered out %d runs with missing scIB metrics: %s",
|
|
188
|
+
len(self.missing_metrics_runs),
|
|
189
|
+
self.missing_metrics_runs[:5],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Update filter count
|
|
193
|
+
self.n_runs_filtered = len(self.missing_config_runs) + len(self.missing_metrics_runs)
|
|
194
|
+
|
|
195
|
+
# Create dataframes
|
|
196
|
+
if not scib_metrics_data:
|
|
197
|
+
logger.warning("No valid runs found with both config and scIB metrics")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
full_metrics_df = pd.DataFrame(scib_metrics_data).set_index("run_id")
|
|
201
|
+
full_other_logs_df = pd.DataFrame(other_logs_data).set_index("run_id")
|
|
202
|
+
|
|
203
|
+
# Organize by method - create method-specific config DataFrames without NaN columns
|
|
204
|
+
# Use runs that passed BOTH config and scIB metrics validation
|
|
205
|
+
valid_run_ids = set(full_metrics_df.index) # These runs have both valid configs AND scIB metrics
|
|
206
|
+
|
|
207
|
+
methods = {row["config"]["method"] for _, row in valid_df.iterrows() if row["run_id"] in valid_run_ids}
|
|
208
|
+
for method in methods:
|
|
209
|
+
# Get runs for this method that have BOTH valid configs AND scIB metrics
|
|
210
|
+
method_rows = [
|
|
211
|
+
row
|
|
212
|
+
for _, row in valid_df.iterrows()
|
|
213
|
+
if row["config"]["method"] == method and row["run_id"] in valid_run_ids
|
|
214
|
+
]
|
|
215
|
+
method_run_ids = [row["run_id"] for row in method_rows]
|
|
216
|
+
|
|
217
|
+
# Create method-specific config DataFrame preserving original structure
|
|
218
|
+
method_configs = []
|
|
219
|
+
for row in method_rows:
|
|
220
|
+
config_entry = {"run_id": row["run_id"], **row["config"]}
|
|
221
|
+
method_configs.append(config_entry)
|
|
222
|
+
|
|
223
|
+
method_config_df = pd.DataFrame(method_configs).set_index("run_id")
|
|
224
|
+
method_metrics_df = full_metrics_df.loc[method_run_ids]
|
|
225
|
+
method_other_logs_df = full_other_logs_df.loc[method_run_ids]
|
|
226
|
+
|
|
227
|
+
# Create Benchmarker object for this method's scIB metrics
|
|
228
|
+
try:
|
|
229
|
+
benchmarker = self._create_benchmarker_for_method(method_metrics_df)
|
|
230
|
+
except (ValueError, KeyError, ImportError) as e:
|
|
231
|
+
logger.warning("Failed to create Benchmarker for method %s: %s", method, e)
|
|
232
|
+
# Fallback to storing as DataFrame if Benchmarker creation fails
|
|
233
|
+
benchmarker = method_metrics_df
|
|
234
|
+
|
|
235
|
+
self.method_data[method] = {
|
|
236
|
+
"configs": method_config_df,
|
|
237
|
+
"scib_benchmarker": benchmarker,
|
|
238
|
+
"other_logs": method_other_logs_df,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
logger.info("Available scIB metrics: %s", sorted(self.available_scib_metrics))
|
|
242
|
+
|
|
243
|
+
def _build_metric_type_mapping(self) -> dict[str, str]:
|
|
244
|
+
"""Build mapping from metric names to metric types using scIB dataclasses."""
|
|
245
|
+
metric_to_type = {}
|
|
246
|
+
|
|
247
|
+
for name, display_name in metric_name_cleaner.items():
|
|
248
|
+
if any(name.startswith(field.name) for field in fields(BioConservation)):
|
|
249
|
+
metric_to_type[display_name] = "Bio conservation"
|
|
250
|
+
if any(name.startswith(field.name) for field in fields(BatchCorrection)):
|
|
251
|
+
metric_to_type[display_name] = "Batch correction"
|
|
252
|
+
|
|
253
|
+
return metric_to_type
|
|
254
|
+
|
|
255
|
+
def _create_benchmarker_for_method(self, scib_df: pd.DataFrame) -> Benchmarker:
|
|
256
|
+
"""Create a Benchmarker object for scIB metrics results.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
scib_df
|
|
261
|
+
DataFrame with scIB metrics (runs as rows, metrics as columns)
|
|
262
|
+
|
|
263
|
+
Returns
|
|
264
|
+
-------
|
|
265
|
+
Benchmarker
|
|
266
|
+
Benchmarker object with results loaded
|
|
267
|
+
"""
|
|
268
|
+
# Transpose so metrics are rows and runs are columns (as expected by scIB)
|
|
269
|
+
scib_plot = scib_df.drop(columns=["method"]).T.copy()
|
|
270
|
+
|
|
271
|
+
# Add metric type information
|
|
272
|
+
scib_plot["Metric Type"] = scib_plot.index.map(self.metric_to_type)
|
|
273
|
+
|
|
274
|
+
# Filter to only metrics that have type mappings
|
|
275
|
+
valid_metrics = [metric for metric in scib_plot.index if metric in self.metric_to_type]
|
|
276
|
+
scib_plot = scib_plot.loc[valid_metrics]
|
|
277
|
+
|
|
278
|
+
# Reorder rows based on the order in self.metric_to_type
|
|
279
|
+
ordered_metrics = [metric for metric in self.metric_to_type.keys() if metric in valid_metrics]
|
|
280
|
+
scib_plot = scib_plot.loc[ordered_metrics]
|
|
281
|
+
|
|
282
|
+
# Create minimal dummy AnnData for Benchmarker initialization
|
|
283
|
+
n_obs = 10
|
|
284
|
+
n_vars = 5
|
|
285
|
+
dummy_adata = sc.AnnData(np.random.random((n_obs, n_vars)))
|
|
286
|
+
dummy_adata.obs["batch"] = ["batch1", "batch2"] * (n_obs // 2)
|
|
287
|
+
dummy_adata.obs["celltype"] = ["type1", "type2"] * (n_obs // 2)
|
|
288
|
+
|
|
289
|
+
# Create Benchmarker instance
|
|
290
|
+
|
|
291
|
+
bm = Benchmarker(
|
|
292
|
+
adata=dummy_adata,
|
|
293
|
+
batch_key="batch",
|
|
294
|
+
label_key="celltype",
|
|
295
|
+
embedding_obsm_keys=list(scib_plot.columns[:-1]), # Exclude 'Metric Type' column
|
|
296
|
+
bio_conservation_metrics=BioConservation(),
|
|
297
|
+
batch_correction_metrics=BatchCorrection(),
|
|
298
|
+
n_jobs=-1,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Inject the results directly (skip actual benchmarking)
|
|
302
|
+
bm._results = scib_plot
|
|
303
|
+
bm._benchmarked = True # Mark as benchmarked to avoid re-running
|
|
304
|
+
|
|
305
|
+
return bm
|
|
306
|
+
|
|
307
|
+
def aggregate(self, sort_by: str = "Total", min_max_scale_for_selection: bool = True) -> None:
|
|
308
|
+
"""
|
|
309
|
+
Aggregate best run per method into unified results structure.
|
|
310
|
+
|
|
311
|
+
Creates self.results with the same structure as individual method results:
|
|
312
|
+
- configs: DataFrame with best configurations per method
|
|
313
|
+
- scib_benchmarker: Benchmarker object with all best runs
|
|
314
|
+
- other_logs: DataFrame with other logs from best runs per method
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
sort_by
|
|
319
|
+
Metric to sort by for selecting best run per method.
|
|
320
|
+
Default is "Total" (overall scIB score).
|
|
321
|
+
min_max_scale_for_selection
|
|
322
|
+
Whether to use min-max scaled metrics for selecting the best run per method.
|
|
323
|
+
If True, uses scaled metrics for fair comparison across different metric ranges.
|
|
324
|
+
If False, uses raw metrics for selection.
|
|
325
|
+
Default is True. Note: reported metrics are always raw (non-scaled) values.
|
|
326
|
+
"""
|
|
327
|
+
if not self.method_data:
|
|
328
|
+
raise ValueError("No data available. Call fetch_runs() first.")
|
|
329
|
+
|
|
330
|
+
best_configs = []
|
|
331
|
+
best_metrics = []
|
|
332
|
+
best_other_logs = []
|
|
333
|
+
|
|
334
|
+
for method, data in self.method_data.items():
|
|
335
|
+
benchmarker = data["scib_benchmarker"]
|
|
336
|
+
configs_df = data["configs"]
|
|
337
|
+
other_logs_df = data["other_logs"]
|
|
338
|
+
|
|
339
|
+
# Get metrics DataFrame from Benchmarker for SELECTION (user choice of scaling)
|
|
340
|
+
selection_results_df = benchmarker.get_results(min_max_scale=min_max_scale_for_selection)
|
|
341
|
+
# Remove the "Metric Type" row if present
|
|
342
|
+
if "Metric Type" in selection_results_df.index:
|
|
343
|
+
selection_metrics_df = selection_results_df.drop(index="Metric Type")
|
|
344
|
+
else:
|
|
345
|
+
selection_metrics_df = selection_results_df
|
|
346
|
+
|
|
347
|
+
if sort_by not in selection_metrics_df.columns:
|
|
348
|
+
logger.warning("Metric '%s' not found for method '%s', skipping", sort_by, method)
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# Find best run using the selection metrics (scaled or unscaled as requested)
|
|
352
|
+
best_idx = selection_metrics_df[sort_by].idxmax()
|
|
353
|
+
|
|
354
|
+
# Get metrics DataFrame for REPORTING (always unscaled)
|
|
355
|
+
reporting_results_df = benchmarker.get_results(min_max_scale=False)
|
|
356
|
+
# Remove the "Metric Type" row if present
|
|
357
|
+
if "Metric Type" in reporting_results_df.index:
|
|
358
|
+
reporting_metrics_df = reporting_results_df.drop(index="Metric Type")
|
|
359
|
+
else:
|
|
360
|
+
reporting_metrics_df = reporting_results_df
|
|
361
|
+
|
|
362
|
+
# Collect best run data
|
|
363
|
+
best_config = configs_df.loc[best_idx].copy()
|
|
364
|
+
best_config["run_id"] = best_idx # Add run_id from the index
|
|
365
|
+
best_config.name = method # Use method name as index
|
|
366
|
+
best_configs.append(best_config)
|
|
367
|
+
|
|
368
|
+
# Extract metrics for best run using REPORTING metrics (always unscaled)
|
|
369
|
+
best_metric = reporting_metrics_df.loc[best_idx].copy()
|
|
370
|
+
best_metric.name = method
|
|
371
|
+
best_metrics.append(best_metric)
|
|
372
|
+
|
|
373
|
+
# Collect other logs for best run
|
|
374
|
+
best_other = other_logs_df.loc[best_idx].copy()
|
|
375
|
+
best_other.name = method
|
|
376
|
+
best_other_logs.append(best_other)
|
|
377
|
+
|
|
378
|
+
if not best_configs:
|
|
379
|
+
logger.warning("No valid runs found with metric '%s'", sort_by)
|
|
380
|
+
self.results = None
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# Create aggregated DataFrames
|
|
384
|
+
configs_df = pd.DataFrame(best_configs)
|
|
385
|
+
|
|
386
|
+
# Remove redundant 'method' column (since DataFrame is indexed by method)
|
|
387
|
+
if "method" in configs_df.columns:
|
|
388
|
+
configs_df = configs_df.drop(columns=["method"])
|
|
389
|
+
|
|
390
|
+
# Ensure 'run_id' is the first column
|
|
391
|
+
if "run_id" in configs_df.columns:
|
|
392
|
+
cols = ["run_id"] + [col for col in configs_df.columns if col != "run_id"]
|
|
393
|
+
configs_df = configs_df[cols]
|
|
394
|
+
|
|
395
|
+
other_logs_df = pd.DataFrame(best_other_logs)
|
|
396
|
+
|
|
397
|
+
# Create metrics DataFrame and Benchmarker
|
|
398
|
+
metrics_df = pd.DataFrame(best_metrics)
|
|
399
|
+
metrics_df["method"] = metrics_df.index
|
|
400
|
+
|
|
401
|
+
# Create Benchmarker object for aggregated results
|
|
402
|
+
try:
|
|
403
|
+
benchmarker = self._create_benchmarker_for_method(metrics_df)
|
|
404
|
+
except (ValueError, KeyError, ImportError) as e:
|
|
405
|
+
logger.warning("Failed to create aggregated Benchmarker: %s", e)
|
|
406
|
+
# Fallback to storing as DataFrame
|
|
407
|
+
benchmarker = metrics_df
|
|
408
|
+
|
|
409
|
+
# Store results in the same format as individual methods
|
|
410
|
+
self.results = {
|
|
411
|
+
"configs": configs_df,
|
|
412
|
+
"scib_benchmarker": benchmarker,
|
|
413
|
+
"other_logs": other_logs_df,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
logger.info("Aggregated best runs for %d methods using metric '%s'", len(best_configs), sort_by)
|
|
417
|
+
|
|
418
|
+
def get_method_runs(
|
|
419
|
+
self, method: str, sort_by: str = "Total", min_max_scale: bool = True
|
|
420
|
+
) -> dict[str, pd.DataFrame | Benchmarker]:
|
|
421
|
+
"""
|
|
422
|
+
Get all data for a specific method, optionally sorted by a metric.
|
|
423
|
+
|
|
424
|
+
Parameters
|
|
425
|
+
----------
|
|
426
|
+
method
|
|
427
|
+
Method name to retrieve data for.
|
|
428
|
+
sort_by
|
|
429
|
+
Metric name to sort runs by. Default is "Total".
|
|
430
|
+
Must be a valid metric name (e.g., 'Total', 'Silhouette label', etc.).
|
|
431
|
+
min_max_scale
|
|
432
|
+
Whether to use min-max scaled metrics for sorting. Default is True.
|
|
433
|
+
If True, uses scaled metrics for fair comparison across different metric ranges.
|
|
434
|
+
If False, uses raw metrics for sorting.
|
|
435
|
+
|
|
436
|
+
Returns
|
|
437
|
+
-------
|
|
438
|
+
dict
|
|
439
|
+
Dictionary with 'configs' (DataFrame), 'scib_benchmarker' (Benchmarker),
|
|
440
|
+
and 'other_logs' (DataFrame). The 'configs' and 'other_logs' DataFrames
|
|
441
|
+
are sorted by the specified metric in descending order (best runs first).
|
|
442
|
+
"""
|
|
443
|
+
if method not in self.method_data:
|
|
444
|
+
raise ValueError(f"Method '{method}' not found. Available methods: {list(self.method_data.keys())}")
|
|
445
|
+
|
|
446
|
+
method_data = self.method_data[method].copy()
|
|
447
|
+
|
|
448
|
+
# Get benchmarker and extract results for sorting
|
|
449
|
+
benchmarker = method_data["scib_benchmarker"]
|
|
450
|
+
|
|
451
|
+
# Get results from Benchmarker
|
|
452
|
+
results_df = benchmarker.get_results(min_max_scale=min_max_scale)
|
|
453
|
+
|
|
454
|
+
# Remove 'Metric Type' row if present
|
|
455
|
+
if "Metric Type" in results_df.index:
|
|
456
|
+
results_df = results_df.drop(index="Metric Type")
|
|
457
|
+
|
|
458
|
+
if sort_by not in results_df.columns:
|
|
459
|
+
available_metrics = list(results_df.columns)
|
|
460
|
+
raise ValueError(f"Metric '{sort_by}' not found. Available metrics: {available_metrics}")
|
|
461
|
+
|
|
462
|
+
# Sort by the specified metric (descending - best first)
|
|
463
|
+
sorted_order = results_df[sort_by].sort_values(ascending=False).index
|
|
464
|
+
|
|
465
|
+
# Apply sorting to configs and other_logs DataFrames
|
|
466
|
+
method_data["configs"] = method_data["configs"].loc[sorted_order]
|
|
467
|
+
method_data["other_logs"] = method_data["other_logs"].loc[sorted_order]
|
|
468
|
+
|
|
469
|
+
return method_data
|
|
470
|
+
|
|
471
|
+
def get_models_and_embeddings(
|
|
472
|
+
self,
|
|
473
|
+
model_artifact_name: str = "trained_model",
|
|
474
|
+
embedding_artifact_name: str = "embedding",
|
|
475
|
+
) -> None:
|
|
476
|
+
"""
|
|
477
|
+
Download models and embeddings for the best-performing run per method.
|
|
478
|
+
|
|
479
|
+
Creates a folder structure in output_dir with models and embeddings organized by method.
|
|
480
|
+
Models are only downloaded for methods that have them (GPU-based methods).
|
|
481
|
+
|
|
482
|
+
Parameters
|
|
483
|
+
----------
|
|
484
|
+
model_artifact_name
|
|
485
|
+
Name of the model artifact in wandb.
|
|
486
|
+
embedding_artifact_name
|
|
487
|
+
Name of the embedding artifact in wandb.
|
|
488
|
+
"""
|
|
489
|
+
if self.results is None:
|
|
490
|
+
raise ValueError("Must call aggregate() first to determine best runs per method")
|
|
491
|
+
|
|
492
|
+
configs_df = self.results["configs"]
|
|
493
|
+
assert isinstance(configs_df, pd.DataFrame), "configs should always be a DataFrame"
|
|
494
|
+
|
|
495
|
+
for method in configs_df.index:
|
|
496
|
+
row = configs_df.loc[method]
|
|
497
|
+
run_id = row["run_id"]
|
|
498
|
+
|
|
499
|
+
logger.debug("Processing method '%s', run_id: %s", method, run_id)
|
|
500
|
+
|
|
501
|
+
# Create method directory
|
|
502
|
+
method_dir = self.output_dir / method
|
|
503
|
+
method_dir.mkdir(parents=True, exist_ok=True)
|
|
504
|
+
|
|
505
|
+
# Create subdirectories
|
|
506
|
+
models_dir = method_dir / "models"
|
|
507
|
+
embeddings_dir = method_dir / "embeddings"
|
|
508
|
+
models_dir.mkdir(exist_ok=True)
|
|
509
|
+
embeddings_dir.mkdir(exist_ok=True)
|
|
510
|
+
|
|
511
|
+
# Track what was downloaded for summary
|
|
512
|
+
downloaded_items = []
|
|
513
|
+
missing_items = []
|
|
514
|
+
|
|
515
|
+
# Try to download model (may not exist for CPU methods)
|
|
516
|
+
model_path = _download_artifact_by_run_id(
|
|
517
|
+
run_id=run_id,
|
|
518
|
+
entity=self.entity,
|
|
519
|
+
project=self.project,
|
|
520
|
+
artifact_name=model_artifact_name,
|
|
521
|
+
download_dir=models_dir,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if model_path is None:
|
|
525
|
+
logger.debug("No model artifact found for method '%s'", method)
|
|
526
|
+
missing_items.append("model")
|
|
527
|
+
else:
|
|
528
|
+
logger.debug("Downloaded model for method '%s'", method)
|
|
529
|
+
downloaded_items.append("models")
|
|
530
|
+
|
|
531
|
+
# Try to download embedding
|
|
532
|
+
embedding_path = _download_artifact_by_run_id(
|
|
533
|
+
run_id=run_id,
|
|
534
|
+
entity=self.entity,
|
|
535
|
+
project=self.project,
|
|
536
|
+
artifact_name=embedding_artifact_name,
|
|
537
|
+
download_dir=embeddings_dir,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if embedding_path is None:
|
|
541
|
+
missing_items.append("embedding")
|
|
542
|
+
else:
|
|
543
|
+
downloaded_items.append("embeddings")
|
|
544
|
+
|
|
545
|
+
# Summary logging per method
|
|
546
|
+
if downloaded_items and missing_items:
|
|
547
|
+
logger.info(
|
|
548
|
+
"Downloaded %s for method '%s' to %s/%s/",
|
|
549
|
+
" and ".join(downloaded_items),
|
|
550
|
+
method,
|
|
551
|
+
self.output_dir,
|
|
552
|
+
method,
|
|
553
|
+
)
|
|
554
|
+
logger.warning("Missing %s for method '%s' (run_id: %s)", " and ".join(missing_items), method, run_id)
|
|
555
|
+
elif downloaded_items:
|
|
556
|
+
logger.info(
|
|
557
|
+
"Downloaded %s for method '%s' to %s/%s/",
|
|
558
|
+
" and ".join(downloaded_items),
|
|
559
|
+
method,
|
|
560
|
+
self.output_dir,
|
|
561
|
+
method,
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
logger.warning("No artifacts found for method '%s' (run_id: %s)", method, run_id)
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def available_methods(self) -> list[str]:
|
|
568
|
+
"""List of available methods in the data."""
|
|
569
|
+
return list(self.method_data.keys())
|
|
570
|
+
|
|
571
|
+
def __repr__(self) -> str:
|
|
572
|
+
"""String representation with data summary."""
|
|
573
|
+
if self.raw_df is None:
|
|
574
|
+
return f"scIBAggregator({self.entity}/{self.project}, no data fetched)"
|
|
575
|
+
|
|
576
|
+
n_methods = len(self.method_data)
|
|
577
|
+
n_valid_runs = self.n_runs_fetched - self.n_runs_filtered
|
|
578
|
+
n_metrics = len(self.available_scib_metrics)
|
|
579
|
+
|
|
580
|
+
status_parts = []
|
|
581
|
+
if self.missing_config_runs:
|
|
582
|
+
status_parts.append(f"{len(self.missing_config_runs)} missing configs")
|
|
583
|
+
if self.missing_metrics_runs:
|
|
584
|
+
status_parts.append(f"{len(self.missing_metrics_runs)} missing metrics")
|
|
585
|
+
if self.results is not None:
|
|
586
|
+
status_parts.append("aggregated")
|
|
587
|
+
|
|
588
|
+
status = f" ({', '.join(status_parts)})" if status_parts else ""
|
|
589
|
+
|
|
590
|
+
return (
|
|
591
|
+
f"scIBAggregator("
|
|
592
|
+
f"{self.entity}/{self.project}, "
|
|
593
|
+
f"{n_valid_runs}/{self.n_runs_fetched} runs, "
|
|
594
|
+
f"{n_methods} methods, "
|
|
595
|
+
f"{n_metrics} metrics, "
|
|
596
|
+
f"output_dir='{self.output_dir}'"
|
|
597
|
+
f"{status})"
|
|
598
|
+
)
|