fusemap 0.0.2__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {fusemap-0.0.2 → fusemap-1.1.0}/PKG-INFO +3 -2
  2. {fusemap-0.0.2 → fusemap-1.1.0}/README.md +2 -1
  3. fusemap-1.1.0/fusemap/__init__.py +13 -0
  4. fusemap-1.1.0/fusemap/api.py +285 -0
  5. fusemap-1.1.0/fusemap/config.py +170 -0
  6. fusemap-1.1.0/fusemap/data/__init__.py +0 -0
  7. fusemap-1.1.0/fusemap/data/loaders.py +448 -0
  8. fusemap-1.1.0/fusemap/metrics.py +102 -0
  9. fusemap-1.1.0/fusemap/models/__init__.py +0 -0
  10. fusemap-0.0.2/fusemap/loss.py → fusemap-1.1.0/fusemap/models/losses.py +376 -21
  11. fusemap-1.1.0/fusemap/pl.py +71 -0
  12. fusemap-1.1.0/fusemap/postprocess/__init__.py +0 -0
  13. fusemap-1.1.0/fusemap/postprocess/niche_align.py +283 -0
  14. fusemap-1.1.0/fusemap/postprocess/stage_b_script.py +654 -0
  15. fusemap-1.1.0/fusemap/training/__init__.py +0 -0
  16. fusemap-1.1.0/fusemap/training/ddp.py +293 -0
  17. fusemap-0.0.2/fusemap/spatial_integrate.py → fusemap-1.1.0/fusemap/training/integrate.py +96 -5
  18. fusemap-0.0.2/fusemap/spatial_map.py → fusemap-1.1.0/fusemap/training/map.py +11 -5
  19. fusemap-1.1.0/fusemap/training/sharded.py +408 -0
  20. {fusemap-0.0.2/fusemap → fusemap-1.1.0/fusemap/training}/train_model.py +489 -55
  21. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap/utils.py +3 -1
  22. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap.egg-info/PKG-INFO +3 -2
  23. fusemap-1.1.0/fusemap.egg-info/SOURCES.txt +32 -0
  24. {fusemap-0.0.2 → fusemap-1.1.0}/setup.py +36 -41
  25. fusemap-0.0.2/fusemap/__init__.py +0 -10
  26. fusemap-0.0.2/fusemap/config.py +0 -77
  27. fusemap-0.0.2/fusemap/dataset.py +0 -204
  28. fusemap-0.0.2/fusemap/train.py +0 -247
  29. fusemap-0.0.2/fusemap.egg-info/SOURCES.txt +0 -23
  30. fusemap-0.0.2/fusemap.egg-info/entry_points.txt +0 -2
  31. {fusemap-0.0.2 → fusemap-1.1.0}/LICENSE +0 -0
  32. /fusemap-0.0.2/fusemap/preprocess.py → /fusemap-1.1.0/fusemap/data/graph.py +0 -0
  33. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap/logger.py +0 -0
  34. /fusemap-0.0.2/fusemap/model.py → /fusemap-1.1.0/fusemap/models/network.py +0 -0
  35. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap/permutation.py +0 -0
  36. /fusemap-0.0.2/fusemap/deconvolution.py → /fusemap-1.1.0/fusemap/postprocess/imputation.py +0 -0
  37. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap.egg-info/dependency_links.txt +0 -0
  38. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap.egg-info/requires.txt +0 -0
  39. {fusemap-0.0.2 → fusemap-1.1.0}/fusemap.egg-info/top_level.txt +0 -0
  40. {fusemap-0.0.2 → fusemap-1.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusemap
3
- Version: 0.0.2
3
+ Version: 1.1.0
4
4
  Description-Content-Type: text/markdown
5
5
  License-File: LICENSE
6
6
  Requires-Dist: scanpy==1.9.3
@@ -71,7 +71,8 @@ seaborn
71
71
 
72
72
 
73
73
  ## Installation and Tutorial
74
- - Read the tutorial [here](https://fusemap.readthedocs.io/en/latest/) .
74
+ - Read the FuseMap tutorial [here](https://fusemap.readthedocs.io/en/latest/).
75
+ - FuseMap-Agent set up tutorial [here](https://github.com/wanglab-broad/FuseMap/tree/main/agent_setup).
75
76
 
76
77
 
77
78
  ## Citation
@@ -40,7 +40,8 @@ seaborn
40
40
 
41
41
 
42
42
  ## Installation and Tutorial
43
- - Read the tutorial [here](https://fusemap.readthedocs.io/en/latest/) .
43
+ - Read the FuseMap tutorial [here](https://fusemap.readthedocs.io/en/latest/).
44
+ - FuseMap-Agent set up tutorial [here](https://github.com/wanglab-broad/FuseMap/tree/main/agent_setup).
44
45
 
45
46
 
46
47
  ## Citation
@@ -0,0 +1,13 @@
1
+ from fusemap.training.integrate import *
2
+ from fusemap.training.map import *
3
+ from fusemap.logger import *
4
+ from fusemap.utils import *
5
+ from fusemap.config import *
6
+ from fusemap.models.network import *
7
+ from fusemap.data.loaders import *
8
+ from fusemap.models.losses import *
9
+ from fusemap.training.train_model import *
10
+ from fusemap.data.graph import *
11
+ from fusemap.api import *
12
+ from fusemap import metrics
13
+ from fusemap import pl
@@ -0,0 +1,285 @@
1
+ """One-call convenience API.
2
+
3
+ These wrappers make the common workflows single function calls::
4
+
5
+ import fusemap
6
+
7
+ fusemap.integrate("./data", "./output")
8
+ fusemap.map_to_reference("./new_data", "./output_map", "./pretrained_model")
9
+ fusemap.deconvolve_beads("./output", "./data",
10
+ bead_files="slideseq", sig_ref="starmap")
11
+
12
+ Every argument that ``main.py`` accepts on the command line is available as a
13
+ keyword argument; everything else uses the validated defaults.
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ from pathlib import Path
19
+ from types import SimpleNamespace
20
+
21
+ import scanpy as sc
22
+
23
+ from fusemap.logger import setup_logging
24
+ from fusemap.training.integrate import spatial_integrate
25
+ from fusemap.training.map import spatial_map
26
+ from fusemap.utils import seed_all
27
+
28
+ __all__ = ["integrate", "map_to_reference", "deconvolve_beads", "transfer_labels", "read_input_folder"]
29
+
30
+
31
+ def read_input_folder(input_data_folder_path):
32
+ """Read every ``.h5ad`` in a folder and normalize spatial coordinates.
33
+
34
+ Coordinates are taken from ``obs['x']/obs['y']``, falling back to
35
+ ``obs['col']/obs['row']`` and then ``obsm['spatial']``.
36
+
37
+ Returns
38
+ -------
39
+ list of :class:`anndata.AnnData`, one per section.
40
+ """
41
+ folder = Path(input_data_folder_path)
42
+ file_names = sorted(str(p) for p in folder.iterdir() if p.is_file() and p.suffix == ".h5ad")
43
+ if not file_names:
44
+ raise ValueError(f"No .h5ad files found in {folder}")
45
+
46
+ X_input = []
47
+ for ind, file_name_i in enumerate(file_names):
48
+ X = sc.read_h5ad(file_name_i)
49
+ if "x" not in X.obs.columns:
50
+ if "col" in X.obs.columns and "row" in X.obs.columns:
51
+ X.obs["x"] = X.obs["col"]
52
+ X.obs["y"] = X.obs["row"]
53
+ elif "spatial" in X.obsm:
54
+ X.obs["x"] = X.obsm["spatial"][:, 0]
55
+ X.obs["y"] = X.obsm["spatial"][:, 1]
56
+ else:
57
+ raise ValueError(
58
+ f"{file_name_i}: provide spatial coordinates in obs['x']/obs['y'], "
59
+ "obs['col']/obs['row'], or obsm['spatial']"
60
+ )
61
+ X.obs["name"] = f"section{ind}"
62
+ X.obs["file_name"] = os.path.basename(file_name_i)
63
+ X_input.append(X)
64
+ return X_input
65
+
66
+
67
+ def _make_args(output_save_dir, keep_celltype, keep_tissueregion,
68
+ use_llm_gene_embedding, pretrain_model_path):
69
+ Path(output_save_dir).mkdir(parents=True, exist_ok=True)
70
+ return SimpleNamespace(
71
+ output_save_dir=str(output_save_dir),
72
+ keep_celltype=keep_celltype,
73
+ keep_tissueregion=keep_tissueregion,
74
+ use_llm_gene_embedding=use_llm_gene_embedding,
75
+ pretrain_model_path=str(pretrain_model_path),
76
+ )
77
+
78
+
79
+ def integrate(input_data_folder_path, output_save_dir,
80
+ keep_celltype="", keep_tissueregion="",
81
+ use_llm_gene_embedding="false",
82
+ bead_files=None, sig_ref=None):
83
+ """Integrate all spatial sections in a folder into shared embeddings.
84
+
85
+ Equivalent to ``python main.py --mode integrate``. Training resumes
86
+ automatically from ``snapshot.pt`` if the same command is re-run.
87
+
88
+ Parameters
89
+ ----------
90
+ input_data_folder_path
91
+ Folder with one ``.h5ad`` file per section.
92
+ output_save_dir
93
+ Output directory; embeddings are written here as
94
+ ``ad_celltype_embedding.h5ad``, ``ad_tissueregion_embedding.h5ad``,
95
+ and ``ad_gene_embedding.h5ad``.
96
+ keep_celltype, keep_tissueregion
97
+ Optional ``obs`` column names with existing labels to carry into the
98
+ output embedding files.
99
+ bead_files, sig_ref
100
+ Declare bead/spot-resolution datasets (Slide-seq, Visium ...) and the
101
+ single-cell signature reference(s), as comma-separated file-name
102
+ substrings. When declared, Stage-B deconvolution runs AUTOMATICALLY
103
+ after training (recommended default for any bead platform), and the
104
+ DECOMPOSED embeddings become the canonical outputs
105
+ (``ad_celltype_embedding.h5ad`` / ``ad_tissueregion_embedding.h5ad``);
106
+ the pre-decomposition versions are kept as ``ad_*_embedding_nodeconv.h5ad``.
107
+ Roles are always user-declared, never auto-detected.
108
+ """
109
+ seed_all(0)
110
+ args = _make_args(output_save_dir, keep_celltype, keep_tissueregion,
111
+ use_llm_gene_embedding, "")
112
+ setup_logging(args.output_save_dir)
113
+ logging.info("Arguments: %s", vars(args))
114
+
115
+ X_input = read_input_folder(input_data_folder_path)
116
+ kneighbor = ["delaunay"] * len(X_input)
117
+ input_identity = ["ST"] * len(X_input)
118
+ spatial_integrate(X_input, args, kneighbor, input_identity)
119
+
120
+ if bead_files:
121
+ if not sig_ref:
122
+ raise ValueError("bead_files declared without sig_ref (single-cell signature reference)")
123
+ logging.info("[FuseMap] bead datasets declared - running Stage-B deconvolution")
124
+ deconvolve_beads(output_save_dir, input_data_folder_path, bead_files, sig_ref)
125
+
126
+
127
+ def map_to_reference(input_data_folder_path, output_save_dir, pretrain_model_path,
128
+ keep_celltype="", keep_tissueregion="",
129
+ use_llm_gene_embedding="false"):
130
+ """Map each section in a folder onto a pretrained FuseMap model.
131
+
132
+ Equivalent to ``python main.py --mode map``. Results are written to one
133
+ subdirectory of ``output_save_dir`` per input file.
134
+ """
135
+ import copy as _copy
136
+
137
+ seed_all(0)
138
+ X_input = read_input_folder(input_data_folder_path)
139
+ for X in X_input:
140
+ args_i = _make_args(
141
+ os.path.join(str(output_save_dir), X.obs["file_name"].unique()[0]),
142
+ keep_celltype, keep_tissueregion,
143
+ use_llm_gene_embedding, pretrain_model_path,
144
+ )
145
+ setup_logging(args_i.output_save_dir)
146
+ spatial_map([X], args_i, ["delaunay"], ["ST"])
147
+
148
+
149
+ def deconvolve_beads(output_save_dir, input_data_folder_path,
150
+ bead_files, sig_ref,
151
+ entropy_weight=None, signature_mode=None):
152
+ """Deconvolve bead/spot-resolution datasets after an integration run.
153
+
154
+ Each bead is decomposed into a mixture over cell archetypes learned from
155
+ the single-cell sections (Stage-B); its cell and tissue embeddings are
156
+ rebuilt from the mixture. The decomposed embeddings become the canonical
157
+ ``ad_celltype_embedding.h5ad`` / ``ad_tissueregion_embedding.h5ad`` (bead
158
+ rows replaced; single-cell rows unchanged). The pre-decomposition versions
159
+ are kept as ``ad_*_embedding_nodeconv.h5ad``; ``ad_*_embedding_stageB.h5ad``
160
+ (identical to canonical) and ``stageB_pi.npz`` (per-bead mixtures) are also
161
+ written into ``output_save_dir``.
162
+
163
+ Parameters
164
+ ----------
165
+ output_save_dir
166
+ Output directory of the finished :func:`integrate` run.
167
+ input_data_folder_path
168
+ The data folder that was integrated.
169
+ bead_files
170
+ Comma-separated file-name substrings, each matching exactly one input
171
+ file: the bead-resolution dataset(s) to deconvolve.
172
+ sig_ref
173
+ Comma-separated substrings selecting the single-cell reference
174
+ dataset(s) used to build archetype signatures.
175
+ entropy_weight
176
+ Entropy regularization of the mixture weights (default ``5e-4``).
177
+ signature_mode
178
+ ``"empirical"`` (default, recommended) or ``"decode"``.
179
+ """
180
+ import runpy
181
+
182
+ os.environ["STAGEB_OUT_DIR"] = str(output_save_dir)
183
+ os.environ["STAGEB_DATA_DIR"] = str(input_data_folder_path)
184
+ os.environ["FUSEMAP_BEAD_FILES"] = bead_files
185
+ os.environ["FUSEMAP_SIG_REF"] = sig_ref
186
+ if entropy_weight is not None:
187
+ os.environ["STAGEB_ENT_W"] = str(entropy_weight)
188
+ if signature_mode is not None:
189
+ os.environ["STAGEB_SIG"] = str(signature_mode)
190
+
191
+ script = Path(__file__).resolve().parent / "postprocess" / "stage_b_script.py"
192
+ runpy.run_path(str(script), run_name="__main__")
193
+
194
+ # Decomposed embeddings become the CANONICAL outputs (default for any run
195
+ # with declared bead datasets). The pre-decomposition versions are kept as
196
+ # *_nodeconv.h5ad; *_stageB.h5ad names are kept for compatibility.
197
+ import shutil as _sh
198
+ for lvl in ("celltype", "tissueregion"):
199
+ canon = os.path.join(str(output_save_dir), f"ad_{lvl}_embedding.h5ad")
200
+ stage = os.path.join(str(output_save_dir), f"ad_{lvl}_embedding_stageB.h5ad")
201
+ keep = os.path.join(str(output_save_dir), f"ad_{lvl}_embedding_nodeconv.h5ad")
202
+ if os.path.exists(stage) and os.path.exists(canon):
203
+ if not os.path.exists(keep):
204
+ os.rename(canon, keep)
205
+ _sh.copyfile(stage, canon)
206
+ logging.info("[FuseMap] Stage-B embeddings promoted to canonical outputs "
207
+ "(pre-decomposition kept as *_nodeconv.h5ad)")
208
+
209
+ def transfer_labels(adata, label_key, batch_size=256, epochs=200,
210
+ unlabeled_values=("nan", "Unannotated", ""), device=None):
211
+ """Transfer labels between datasets through the shared embedding — one call.
212
+
213
+ Trains a small classifier on all cells that carry a label in
214
+ ``obs[label_key]`` (the reference) and predicts that label for every cell
215
+ in ``adata``, adding two columns to ``obs``:
216
+
217
+ - ``transfer_<label_key>`` — the transferred label
218
+ - ``transfer_<label_key>_uncertainty`` — 1 - prediction confidence
219
+
220
+ Works on any embedding AnnData produced by :func:`integrate` or
221
+ :func:`map_to_reference` (latent in ``X``). Cells whose label is missing
222
+ or in ``unlabeled_values`` are excluded from training.
223
+
224
+ Returns
225
+ -------
226
+ dict with ``labels`` (np.ndarray), ``uncertainty`` (np.ndarray) and
227
+ ``test_accuracy`` (balanced per-class accuracy on a held-out 10% of the reference).
228
+ """
229
+ import numpy as np
230
+ import torch
231
+ from sklearn import preprocessing
232
+ from torch import nn
233
+ from torch.utils.data import DataLoader, TensorDataset, random_split
234
+
235
+ from fusemap.models.network import NNTransfer
236
+ from fusemap.utils import NNTransferPredictWithUncertainty, NNTransferTrain
237
+
238
+ if device is None:
239
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
240
+
241
+ X = np.asarray(adata.X, dtype=np.float32)
242
+ lab = adata.obs[label_key].astype(str).values
243
+ is_ref = ~np.isin(lab, list(unlabeled_values)) & (lab != "nan")
244
+ if is_ref.sum() < 100:
245
+ raise ValueError(f"only {is_ref.sum()} labeled reference cells for '{label_key}'")
246
+
247
+ le = preprocessing.LabelEncoder()
248
+ y_ref = le.fit_transform(lab[is_ref]).astype(int)
249
+
250
+ ds = TensorDataset(torch.tensor(X[is_ref]), torch.tensor(y_ref).long())
251
+ n_train = int(0.8 * len(ds))
252
+ n_val = int(0.1 * len(ds))
253
+ train_ds, val_ds, test_ds = random_split(
254
+ ds, [n_train, n_val, len(ds) - n_train - n_val])
255
+ loaders = dict(
256
+ train=DataLoader(train_ds, batch_size=batch_size, shuffle=True),
257
+ val=DataLoader(val_ds, batch_size=batch_size, shuffle=False),
258
+ test=DataLoader(test_ds, batch_size=batch_size, shuffle=False),
259
+ )
260
+
261
+ import sklearn.utils
262
+
263
+ model = NNTransfer(input_dim=X.shape[1], output_dim=len(le.classes_)).to(device)
264
+ class_weight = torch.Tensor(sklearn.utils.class_weight.compute_class_weight(
265
+ class_weight="balanced", classes=np.unique(y_ref), y=y_ref))
266
+ criterion = nn.CrossEntropyLoss(weight=class_weight.to(device))
267
+ optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
268
+ NNTransferTrain(model, criterion, optimizer, loaders["train"], loaders["val"],
269
+ device, epochs=epochs)
270
+
271
+ test_pred, _ = NNTransferPredictWithUncertainty(model, loaders["test"], device)
272
+ test_true = np.array([y for _, y in test_ds])
273
+ test_pred = np.array(test_pred)
274
+ # balanced (per-class mean) accuracy: plain accuracy hides rare classes
275
+ accs = [float((test_pred[test_true == c] == c).mean())
276
+ for c in np.unique(test_true) if (test_true == c).sum() > 0]
277
+ test_acc = float(np.mean(accs))
278
+
279
+ all_loader = DataLoader(TensorDataset(torch.tensor(X)), batch_size=batch_size, shuffle=False)
280
+ pred, unc = NNTransferPredictWithUncertainty(model, all_loader, device)
281
+ labels = le.inverse_transform(np.array(pred))
282
+
283
+ adata.obs[f"transfer_{label_key}"] = labels
284
+ adata.obs[f"transfer_{label_key}_uncertainty"] = np.array(unc)
285
+ return {"labels": labels, "uncertainty": np.array(unc), "test_accuracy": test_acc}
@@ -0,0 +1,170 @@
1
+ import argparse
2
+ import os
3
+ from enum import Enum
4
+
5
+
6
+ def parse_input_args():
7
+ parser = argparse.ArgumentParser(description="FuseMap")
8
+
9
+ parser.add_argument(
10
+ "--input_data_folder_path",
11
+ type=str,
12
+ required=True,
13
+ )
14
+ parser.add_argument(
15
+ "--output_save_dir",
16
+ type=str,
17
+ required=True,
18
+ )
19
+ parser.add_argument(
20
+ "--mode",
21
+ type=str,
22
+ required=True,
23
+ )
24
+ parser.add_argument(
25
+ "--keep_celltype",
26
+ type=str,
27
+ default="",
28
+ )
29
+ parser.add_argument(
30
+ "--keep_tissueregion",
31
+ type=str,
32
+ default="",
33
+ )
34
+ parser.add_argument(
35
+ "--use_llm_gene_embedding",
36
+ default='false',
37
+ )
38
+ parser.add_argument(
39
+ "--pretrain_model_path",
40
+ default="",
41
+ )
42
+ parser.add_argument(
43
+ "--bead_files",
44
+ type=str,
45
+ default="",
46
+ help="comma-separated substrings declaring bead/spot-resolution datasets; Stage-B runs automatically after integration",
47
+ )
48
+ parser.add_argument(
49
+ "--sig_ref",
50
+ type=str,
51
+ default="",
52
+ help="comma-separated substrings selecting single-cell signature reference dataset(s) for Stage-B",
53
+ )
54
+ args = parser.parse_args()
55
+ return args
56
+
57
+
58
+ class FlagConfig:
59
+ lambda_disc_single = 1
60
+ align_anneal = 1e10
61
+
62
+
63
+ class ModelType(Enum):
64
+ pca_dim = 50
65
+ hidden_dim = 512
66
+ latent_dim = 64
67
+ dropout_rate = 0.2
68
+ n_epochs = 16
69
+ batch_size = 64
70
+ learning_rate = 0.001
71
+ optim_kw = "RMSprop"
72
+ use_input = "norm"
73
+ lambda_ae_single = 1
74
+ lambda_disc_spatial = 1
75
+ lambda_ae_spatial = 1
76
+ align_noise_coef = 1.5
77
+ lr_patience_pretrain = 2
78
+ lr_factor_pretrain = 0.5
79
+ lr_limit_pretrain = 0.00001
80
+ patience_limit_final = 5
81
+ lr_patience_final = 3
82
+ lr_factor_final = 0.5
83
+ lr_limit_final = 0.00001
84
+ patience_limit_pretrain = 3
85
+ EPS = 1e-10
86
+ DIS_LAMDA = 2
87
+ TRAIN_WITHOUT_EVAL = 10
88
+ USE_REFERENCE_PCT = 0.02
89
+ verbose = False
90
+ use_key='final'
91
+
92
+
93
+ class AnchorConfig:
94
+ """Cross-sample MNN anchor alignment (STAligner-style), loss-level only.
95
+
96
+ This is a plain class (NOT a ``ModelType`` Enum member; the Enum has
97
+ value-aliasing quirks). Setting ``anchor_lambda = 0`` disables every anchor
98
+ code path and reproduces the original FuseMap behavior exactly.
99
+ """
100
+
101
+ # default 0.3 = validated optimal (2026-09-01); override via env var
102
+ anchor_lambda = float(os.environ.get("FUSEMAP_ANCHOR_LAMBDA", "0.3"))
103
+ anchor_refresh_epochs = 2 # recompute MNN anchors every N epochs
104
+ anchor_k = 15 # kNN used per direction for MNN
105
+ anchor_start_epoch = int(os.environ.get("FUSEMAP_ANCHOR_START", "2"))
106
+ # quality filters (2026-09-01): drop low-similarity MNN pairs and pairs not
107
+ # stable across consecutive refreshes; disable anchors during pretrain by
108
+ # default (immature latent locks in wrong anchors).
109
+ anchor_sim_threshold = float(os.environ.get("FUSEMAP_ANCHOR_SIM", "0.5"))
110
+ # comma-separated file-name substrings marking QUERY datasets (e.g. bead
111
+ # data): they get pulled toward others, but nothing is pulled toward them.
112
+ anchor_query_files = os.environ.get("FUSEMAP_ANCHOR_QUERY", "")
113
+ anchor_query_atlases = set() # resolved at runtime from input file names
114
+ anchor_stable_only = os.environ.get("FUSEMAP_ANCHOR_STABLE", "1") == "1"
115
+ anchor_in_pretrain = os.environ.get("FUSEMAP_ANCHOR_PRETRAIN", "0") == "1"
116
+
117
+ # Within-dataset structure-preservation triplet loss (opt-in).
118
+ # struct_lambda = 0 keeps the feature fully OFF and reproduces current
119
+ # behavior EXACTLY (no extra RNG draws, no extra compute anywhere).
120
+ # When > 0, spatial_integrate precomputes per-atlas expression-space kNN
121
+ # (stored at runtime as ``AnchorConfig.struct_knn``) and
122
+ # ``fusemap.loss.compute_struct_loss`` adds a triplet term in the final
123
+ # training phase that keeps each cell's expression neighbors closer in
124
+ # latent space than random same-dataset cells.
125
+ struct_lambda = float(os.environ.get("FUSEMAP_STRUCT_LAMBDA", "0"))
126
+ struct_k = int(os.environ.get("FUSEMAP_STRUCT_K", "10"))
127
+ struct_margin = float(os.environ.get("FUSEMAP_STRUCT_MARGIN", "1.0"))
128
+ # Semi-hard negative mining (default ON when struct loss is enabled):
129
+ # uniformly random negatives are already far apart in the compact latent,
130
+ # so the repulsion term is inert and the triplet degenerates into extra
131
+ # compression (measured: type purity 0.578 -> 0.502). Instead mine the
132
+ # hardest VALID negative among struct_neg_candidates random candidates.
133
+ # Set FUSEMAP_STRUCT_HARDNEG=0 to reproduce the legacy random-negative
134
+ # path bit-for-bit (same number and order of RNG draws).
135
+ struct_hardneg = os.environ.get("FUSEMAP_STRUCT_HARDNEG", "1") == "1"
136
+ struct_neg_candidates = int(os.environ.get("FUSEMAP_STRUCT_NEGC", "16"))
137
+
138
+
139
+ class AnchorState:
140
+ """Runtime container for anchor alignment state.
141
+
142
+ Populated by ``fusemap.train_model.refresh_anchors`` and consumed by the AE
143
+ loss (``fusemap.loss.compute_anchor_loss``). Kept dependency-free (plain
144
+ attributes) so it can live in the config module.
145
+
146
+ Attributes
147
+ ----------
148
+ single_cache : list of torch.Tensor or None
149
+ Per-atlas full-dataset single latent (z_mean), detached, [n_obs_i, d].
150
+ spatial_cache : list of torch.Tensor or None
151
+ Per-atlas full-dataset spatial latent (z_spatial), detached.
152
+ partner : dict
153
+ (i, j) -> int64 tensor of length n_obs_i giving, for each cell in atlas
154
+ i, the index of its MNN partner cell in atlas j (or -1 if none).
155
+ n_pairs : int
156
+ Total number of MNN anchor pairs found at the last refresh.
157
+ mean_dist : float
158
+ Mean cross-atlas single-latent distance of anchored pairs at refresh.
159
+ has_anchors : bool
160
+ Whether any anchors are currently stored.
161
+ """
162
+
163
+ def __init__(self):
164
+ self.prev_pairs = {}
165
+ self.single_cache = None
166
+ self.spatial_cache = None
167
+ self.partner = {}
168
+ self.n_pairs = 0
169
+ self.mean_dist = float("nan")
170
+ self.has_anchors = False
File without changes