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 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
+ )