alhena 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. alhena/__init__.py +148 -0
  2. alhena/_bundle.py +121 -0
  3. alhena/_commands.py +89 -0
  4. alhena/_constants.py +247 -0
  5. alhena/_coverage.py +77 -0
  6. alhena/_defaults.py +35 -0
  7. alhena/_errors.py +75 -0
  8. alhena/_frames.py +90 -0
  9. alhena/_geometry.py +483 -0
  10. alhena/_hub.py +109 -0
  11. alhena/_layout.py +532 -0
  12. alhena/_plotting.py +241 -0
  13. alhena/_progress.py +166 -0
  14. alhena/_rings.py +65 -0
  15. alhena/_shards.py +155 -0
  16. alhena/_truth.py +126 -0
  17. alhena/_version.py +3 -0
  18. alhena/_wording.py +120 -0
  19. alhena/annotate.py +666 -0
  20. alhena/api.py +512 -0
  21. alhena/cells/__init__.py +35 -0
  22. alhena/cells/_canvas.py +298 -0
  23. alhena/cells/_centroids.py +261 -0
  24. alhena/cells/_checks.py +415 -0
  25. alhena/cells/_drawing.py +253 -0
  26. alhena/cells/_inputs.py +465 -0
  27. alhena/cells/_labels.py +255 -0
  28. alhena/cells/_path.py +413 -0
  29. alhena/cells/_polygons.py +400 -0
  30. alhena/cells/_pooling.py +368 -0
  31. alhena/cells/_table.py +321 -0
  32. alhena/cli/__init__.py +32 -0
  33. alhena/cli/_annotate.py +310 -0
  34. alhena/cli/_demo.py +27 -0
  35. alhena/cli/_download.py +66 -0
  36. alhena/cli/_encode.py +209 -0
  37. alhena/cli/_info.py +74 -0
  38. alhena/cli/_options.py +239 -0
  39. alhena/cli/_parser.py +330 -0
  40. alhena/cli/_reading.py +339 -0
  41. alhena/cli/_reference.py +139 -0
  42. alhena/cli/_shared.py +281 -0
  43. alhena/demo/__init__.py +87 -0
  44. alhena/demo/_fetch.py +340 -0
  45. alhena/demo/_main.py +164 -0
  46. alhena/demo/_pannuke.py +355 -0
  47. alhena/demo/_report.py +604 -0
  48. alhena/demo/_run.py +190 -0
  49. alhena/encoder/__init__.py +34 -0
  50. alhena/encoder/_adapters.py +234 -0
  51. alhena/encoder/_inference.py +597 -0
  52. alhena/encoder/_loading.py +368 -0
  53. alhena/hub.py +512 -0
  54. alhena/io/__init__.py +159 -0
  55. alhena/io/_carry.py +158 -0
  56. alhena/io/_centroids.py +421 -0
  57. alhena/io/_files.py +307 -0
  58. alhena/io/_formats.py +134 -0
  59. alhena/io/_grid.py +327 -0
  60. alhena/io/_hdf5.py +328 -0
  61. alhena/io/_images.py +272 -0
  62. alhena/io/_labels.py +523 -0
  63. alhena/io/_nuclei.py +104 -0
  64. alhena/io/_polygons.py +645 -0
  65. alhena/io/_resolution.py +278 -0
  66. alhena/io/_saved.py +447 -0
  67. alhena/io/_slides.py +467 -0
  68. alhena/io/_sources.py +442 -0
  69. alhena/io/_tiles.py +222 -0
  70. alhena/metrics.py +424 -0
  71. alhena/preprocess.py +149 -0
  72. alhena/py.typed +0 -0
  73. alhena/refbuild.py +737 -0
  74. alhena/reference.py +648 -0
  75. alhena/spatial/__init__.py +35 -0
  76. alhena/spatial/_cells.py +221 -0
  77. alhena/spatial/_elements.py +233 -0
  78. alhena/spatial/_nuclei.py +207 -0
  79. alhena/spatial/_sopa.py +200 -0
  80. alhena/spatial/_tiles.py +368 -0
  81. alhena/spatial/_windows.py +180 -0
  82. alhena/types.py +874 -0
  83. alhena/viz.py +780 -0
  84. alhena-0.1.0.dist-info/METADATA +200 -0
  85. alhena-0.1.0.dist-info/RECORD +88 -0
  86. alhena-0.1.0.dist-info/WHEEL +4 -0
  87. alhena-0.1.0.dist-info/entry_points.txt +2 -0
  88. alhena-0.1.0.dist-info/licenses/LICENSE +29 -0
alhena/__init__.py ADDED
@@ -0,0 +1,148 @@
1
+ """ALHENA, an H&E encoder distilled from transcriptomic foundation models
2
+ for multi-scale slide analysis.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ # Loaded with the package so the readers are reached as alhena.io; it imports nothing new.
8
+ from alhena import io as io
9
+ from alhena._constants import (
10
+ BACKBONE_NATIVE_MPP,
11
+ CLASS_AXES,
12
+ CLASS_COLORS,
13
+ COMPARTMENT_V1,
14
+ DEFAULT_AXIS,
15
+ DEFAULT_K,
16
+ DEFAULT_TAU,
17
+ INPUT_SIZE,
18
+ LINEAGE_V1,
19
+ PATCH_SIZE,
20
+ REFERENCE_MPP,
21
+ RETRIEVAL_DEPTH,
22
+ TISSUE_ALIASES,
23
+ TISSUE_TO_SHARD,
24
+ label_order,
25
+ )
26
+ from alhena._errors import (
27
+ AlhenaError,
28
+ AlhenaValueError,
29
+ AlhenaWarning,
30
+ BackboneNotFoundError,
31
+ BundleError,
32
+ CellSpaceMismatchError,
33
+ EnvelopeWarning,
34
+ GeometryError,
35
+ GeometryWarning,
36
+ MembershipError,
37
+ MissingExtraError,
38
+ ReferenceCoverageWarning,
39
+ ReferenceError,
40
+ )
41
+ from alhena._version import __version__ as __version__
42
+ from alhena.annotate import annotate_cells, retrieve, vote
43
+ from alhena.api import CellTyper, load_celltyper, load_encoder
44
+ from alhena.cells import (
45
+ membership_from_centroids,
46
+ membership_from_labels,
47
+ membership_from_polygons,
48
+ pool_cells,
49
+ validate_membership,
50
+ )
51
+ from alhena.encoder import AlhenaEncoder, load_bundle
52
+ from alhena.metrics import align_truth, chance_floor, confidence_sweep, score
53
+ from alhena.preprocess import describe_geometry, prepare_tiles
54
+ from alhena.refbuild import (
55
+ ShardBuildReport,
56
+ build_reference,
57
+ is_user_built,
58
+ read_scconcept_embeddings,
59
+ )
60
+ from alhena.reference import (
61
+ Reference,
62
+ available_shards,
63
+ available_tissues,
64
+ describe_shard,
65
+ load_reference,
66
+ shard_for_tissue,
67
+ )
68
+ from alhena.types import (
69
+ AnnotationResult,
70
+ BundleInfo,
71
+ CellEmbeddings,
72
+ GeometryReport,
73
+ ReferenceInfo,
74
+ ResampleInfo,
75
+ ScoreReport,
76
+ SelfCheckReport,
77
+ TileEmbeddings,
78
+ )
79
+
80
+ __all__ = [
81
+ "BACKBONE_NATIVE_MPP",
82
+ "CLASS_AXES",
83
+ "CLASS_COLORS",
84
+ "COMPARTMENT_V1",
85
+ "DEFAULT_AXIS",
86
+ "DEFAULT_K",
87
+ "DEFAULT_TAU",
88
+ "INPUT_SIZE",
89
+ "LINEAGE_V1",
90
+ "PATCH_SIZE",
91
+ "REFERENCE_MPP",
92
+ "RETRIEVAL_DEPTH",
93
+ "TISSUE_ALIASES",
94
+ "TISSUE_TO_SHARD",
95
+ "AlhenaEncoder",
96
+ "AlhenaError",
97
+ "AlhenaValueError",
98
+ "AlhenaWarning",
99
+ "AnnotationResult",
100
+ "BackboneNotFoundError",
101
+ "BundleError",
102
+ "BundleInfo",
103
+ "CellEmbeddings",
104
+ "CellSpaceMismatchError",
105
+ "CellTyper",
106
+ "EnvelopeWarning",
107
+ "GeometryError",
108
+ "GeometryReport",
109
+ "GeometryWarning",
110
+ "MembershipError",
111
+ "MissingExtraError",
112
+ "Reference",
113
+ "ReferenceCoverageWarning",
114
+ "ReferenceError",
115
+ "ReferenceInfo",
116
+ "ResampleInfo",
117
+ "ScoreReport",
118
+ "SelfCheckReport",
119
+ "ShardBuildReport",
120
+ "TileEmbeddings",
121
+ "__version__",
122
+ "align_truth",
123
+ "annotate_cells",
124
+ "available_shards",
125
+ "available_tissues",
126
+ "build_reference",
127
+ "chance_floor",
128
+ "confidence_sweep",
129
+ "describe_geometry",
130
+ "describe_shard",
131
+ "is_user_built",
132
+ "label_order",
133
+ "load_bundle",
134
+ "load_celltyper",
135
+ "load_encoder",
136
+ "load_reference",
137
+ "membership_from_centroids",
138
+ "membership_from_labels",
139
+ "membership_from_polygons",
140
+ "pool_cells",
141
+ "prepare_tiles",
142
+ "read_scconcept_embeddings",
143
+ "retrieve",
144
+ "score",
145
+ "shard_for_tissue",
146
+ "validate_membership",
147
+ "vote",
148
+ ]
alhena/_bundle.py ADDED
@@ -0,0 +1,121 @@
1
+ """What a bundle declares about its weights, and the identity computed from them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+ from typing import Any, TypeVar
9
+
10
+ import torch
11
+
12
+ from alhena._constants import SCCONCEPT_MODEL, SCCONCEPT_VERSION
13
+ from alhena._errors import BundleError
14
+
15
+ _CELL_SPACE_TAG = "alhena-cell-space-v1"
16
+ _IDENTITY_DIGITS = 16
17
+
18
+ # What each object of a bundle configuration holds, as the refusal of a malformed one names it.
19
+ _BUNDLE_SECTIONS: dict[str, str] = {
20
+ "adapters": "the rank and the scaling of the low-rank adapters",
21
+ "dims": "the widths of the embedding and of the cell space",
22
+ "teacher": "the single-cell model the cell space is aligned to",
23
+ "backbone": "the architecture and the repository of the backbone",
24
+ }
25
+
26
+ _Declared = TypeVar("_Declared", int, float, str)
27
+
28
+
29
+ def _tensor_digest(tensor: torch.Tensor | None) -> str:
30
+ """Return the digest of a tensor's float32 bytes, or an empty field when it is absent."""
31
+ if tensor is None:
32
+ return ""
33
+ values = tensor.detach().to(dtype=torch.float32).cpu().contiguous().numpy()
34
+ return hashlib.sha256(values.tobytes()).hexdigest()
35
+
36
+
37
+ def bundle_identity(
38
+ adapter_bytes: bytes,
39
+ head_tensors: Mapping[str, torch.Tensor],
40
+ scconcept_model: str = SCCONCEPT_MODEL,
41
+ scconcept_version: str = SCCONCEPT_VERSION,
42
+ ) -> tuple[str, str]:
43
+ """Return the encoder identity and the cell space identity of a bundle.
44
+
45
+ Each is the first sixteen hexadecimal digits of a SHA-256 digest: of the adapter file, and of
46
+ `alhena-cell-space-v1`, the adapter digest, the digests of the cell and reference head weights,
47
+ the single-cell model name and its version, joined by `|`.
48
+
49
+ Args:
50
+ adapter_bytes: Contents of the adapter file.
51
+ head_tensors: Head weights, keyed as in the bundle.
52
+ scconcept_model: Single-cell model the reference embeddings come from.
53
+ scconcept_version: Version of that model.
54
+
55
+ Returns:
56
+ The encoder identity and the cell space identity.
57
+ """
58
+ adapter_digest = hashlib.sha256(adapter_bytes).hexdigest()
59
+ fields = (
60
+ _CELL_SPACE_TAG,
61
+ adapter_digest,
62
+ _tensor_digest(head_tensors.get("img_tok_proj.weight")),
63
+ _tensor_digest(head_tensors.get("st_node_proj.weight")),
64
+ scconcept_model,
65
+ scconcept_version,
66
+ )
67
+ cell_space_digest = hashlib.sha256("|".join(fields).encode("utf-8")).hexdigest()
68
+ return adapter_digest[:_IDENTITY_DIGITS], cell_space_digest[:_IDENTITY_DIGITS]
69
+
70
+
71
+ def _declared_property(
72
+ directory: Path,
73
+ config: Mapping[str, Any],
74
+ section: str,
75
+ name: str,
76
+ default: _Declared,
77
+ ) -> _Declared:
78
+ """Read a quantity a bundle states about itself, falling back to the package's own value."""
79
+ declared = config.get(section)
80
+ if declared is None:
81
+ return default
82
+ if not isinstance(declared, dict):
83
+ raise BundleError(
84
+ f"The bundle at {directory} states `{section}` as a value of type "
85
+ f"{type(declared).__name__} where an object naming {_BUNDLE_SECTIONS[section]} is "
86
+ "expected. Load a bundle whose configuration follows the published layout."
87
+ )
88
+ if name not in declared:
89
+ return default
90
+ value = declared[name]
91
+ if isinstance(default, str):
92
+ if not isinstance(value, str):
93
+ raise BundleError(
94
+ f"The bundle at {directory} states {section}.{name} as {value!r} where a string "
95
+ "is expected. Load a bundle whose configuration follows the published layout."
96
+ )
97
+ elif isinstance(value, bool) or not isinstance(value, (int, float)):
98
+ raise BundleError(
99
+ f"The bundle at {directory} states {section}.{name} as {value!r} where a number is "
100
+ "expected. Load a bundle whose configuration follows the published layout."
101
+ )
102
+ if isinstance(default, int) and not float(value).is_integer():
103
+ raise BundleError(
104
+ f"The bundle at {directory} states {section}.{name} as {value!r} where an integer "
105
+ "is expected. Load a bundle whose configuration follows the published layout."
106
+ )
107
+ return type(default)(value)
108
+
109
+
110
+ def _check_stated_identity(
111
+ directory: Path, config: Mapping[str, Any], encoder_id: str, cell_space_id: str
112
+ ) -> None:
113
+ """Refuse a bundle whose configuration states an identity its weights do not produce."""
114
+ produced = {"encoder_id": encoder_id, "cell_space_id": cell_space_id}
115
+ for name, value in produced.items():
116
+ stated = config.get(name)
117
+ if isinstance(stated, str) and stated != value:
118
+ raise BundleError(
119
+ f"The bundle at {directory} states {name} {stated!r} where its weights produce "
120
+ f"{value!r}. The configuration and the weights come from different runs."
121
+ )
alhena/_commands.py ADDED
@@ -0,0 +1,89 @@
1
+ """What the commands and the worked example share: their common options, and how they print."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import warnings
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+
13
+
14
+ def _add_cache_option(parser: argparse.ArgumentParser) -> None:
15
+ parser.add_argument(
16
+ "--cache-dir",
17
+ type=Path,
18
+ default=None,
19
+ help="directory downloaded artifacts are cached in, overriding ALHENA_CACHE",
20
+ )
21
+
22
+
23
+ def _add_quiet_option(parser: argparse.ArgumentParser) -> None:
24
+ parser.add_argument(
25
+ "-q",
26
+ "--quiet",
27
+ action="store_true",
28
+ help=(
29
+ "report no progress on standard error, download bars included; the results are "
30
+ "printed as usual"
31
+ ),
32
+ )
33
+
34
+
35
+ def _add_backbone_option(parser: argparse.ArgumentParser) -> None:
36
+ parser.add_argument(
37
+ "--backbone",
38
+ type=Path,
39
+ default=None,
40
+ help=(
41
+ "the UNI2-h weight file, or a directory holding one; ALHENA_BACKBONE, then the "
42
+ "Hugging Face cache, by default"
43
+ ),
44
+ )
45
+
46
+
47
+ def _add_model_options(parser: argparse.ArgumentParser) -> None:
48
+ _add_backbone_option(parser)
49
+ parser.add_argument(
50
+ "--bundle",
51
+ default=None,
52
+ help=(
53
+ "directory holding the bundle files, or a repository owner/name; the published one "
54
+ "by default"
55
+ ),
56
+ )
57
+ _add_cache_option(parser)
58
+ parser.add_argument(
59
+ "--device",
60
+ default=None,
61
+ help=(
62
+ "device to run on: cpu, cuda, or mps for the GPU of an Apple silicon Mac; CUDA "
63
+ "where available, the processor otherwise"
64
+ ),
65
+ )
66
+
67
+
68
+ def _one_line(text: object) -> str:
69
+ """Collapse a message onto one line, so a report never breaks in the middle of a field."""
70
+ return " ".join(str(text).split())
71
+
72
+
73
+ def _share(count: int, total: int) -> str:
74
+ """Return a count as a share of a total, or a dash where the total is zero."""
75
+ return f"{count / total:.1%}" if total else "-"
76
+
77
+
78
+ def _fixed(value: float, width: int, decimals: int = 3) -> str:
79
+ """Return a right-aligned number, or a dash where it is undefined."""
80
+ return f"{value:>{width}.{decimals}f}" if np.isfinite(value) else f"{'-':>{width}}"
81
+
82
+
83
+ @contextmanager
84
+ def _ignoring(*categories: type[Warning]) -> Iterator[None]:
85
+ """Leave the warnings of the given categories a block raises unreported."""
86
+ with warnings.catch_warnings():
87
+ for category in categories:
88
+ warnings.simplefilter("ignore", category)
89
+ yield
alhena/_constants.py ADDED
@@ -0,0 +1,247 @@
1
+ """Fixed quantities of the released ALHENA model and its reference registry.
2
+
3
+ Nothing here is a tunable: changing a value produces a different model, not a different setting.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Final
10
+
11
+ from alhena._errors import AlhenaError, AlhenaValueError
12
+
13
+ # --- Distribution -----------------------------------------------------------------------
14
+
15
+ WEIGHTS_REPO: Final[str] = "prism-oncology/ALHENA"
16
+ """Model hub repository publishing the encoder bundle."""
17
+ REFERENCES_REPO: Final[str] = "prism-oncology/ALHENA-references"
18
+ """Model hub repository publishing the reference shards and their registry."""
19
+ HUB_REVISION: Final[str] = "v0.1.0"
20
+ """Release tag of the weights and references repositories this version of the package reads."""
21
+ BACKBONE_REPO: Final[str] = "MahmoodLab/UNI2-h"
22
+ """Model hub repository of the UNI2-h weights, gated by their authors."""
23
+
24
+ ADAPTERS_FILE: Final[str] = "adapters.safetensors"
25
+ HEADS_FILE: Final[str] = "heads.safetensors"
26
+ BUNDLE_CONFIG_FILE: Final[str] = "config.json"
27
+ REGISTRY_FILE: Final[str] = "registry.json"
28
+
29
+ SHARD_SUFFIX: Final[str] = ".safetensors"
30
+ """Suffix of a reference shard file."""
31
+ SHARD_DIRECTORY: Final[str] = "shards"
32
+ """Folder holding the shards, in the references repository and in a cache root."""
33
+
34
+ # --- Backbone and adapters --------------------------------------------------------------
35
+
36
+ BACKBONE_ARCHITECTURE: Final[str] = "vit_giant_patch14_224"
37
+
38
+ BACKBONE_TIMM_KWARGS: Final[dict[str, object]] = {
39
+ "img_size": 224,
40
+ "patch_size": 14,
41
+ "depth": 24,
42
+ "num_heads": 24,
43
+ "init_values": 1e-5,
44
+ "embed_dim": 1536,
45
+ "mlp_ratio": 2.66667 * 2,
46
+ "num_classes": 0,
47
+ "no_embed_class": True,
48
+ "reg_tokens": 8,
49
+ "dynamic_img_size": True,
50
+ }
51
+
52
+ # Two layer classes of the architecture, resolved by name in encoder.build_backbone.
53
+ BACKBONE_MLP_LAYER: Final[str] = "SwiGLUPacked"
54
+ BACKBONE_ACT_LAYER: Final[str] = "SiLU"
55
+
56
+ LORA_RANK: Final[int] = 64
57
+ LORA_ALPHA: Final[float] = 32.0
58
+
59
+ BACKBONE_DIM: Final[int] = 1536
60
+ CELL_SPACE_DIM: Final[int] = 512
61
+ SCCONCEPT_DIM: Final[int] = 512
62
+
63
+ NUM_PREFIX_TOKENS: Final[int] = 9
64
+
65
+ # --- Image geometry ---------------------------------------------------------------------
66
+
67
+ INPUT_SIZE: Final[int] = 224
68
+ """Side of the square canvas the encoder reads, in pixels."""
69
+ PATCH_SIZE: Final[int] = 14
70
+ """Side of one token, in pixels of the canvas."""
71
+ TOKEN_GRID: Final[int] = INPUT_SIZE // PATCH_SIZE
72
+ """Tokens along each side of the canvas."""
73
+ NUM_TOKENS: Final[int] = TOKEN_GRID * TOKEN_GRID
74
+ """Patch tokens of one canvas."""
75
+
76
+ IMAGE_MEAN: Final[tuple[float, float, float]] = (0.485, 0.456, 0.406)
77
+ IMAGE_STD: Final[tuple[float, float, float]] = (0.229, 0.224, 0.225)
78
+ IMAGE_INTERPOLATION: Final[str] = "bilinear"
79
+
80
+ # Microns per pixel the adapters were distilled at.
81
+ DISTILLATION_MPP: Final[float] = 0.425
82
+ BACKBONE_NATIVE_MPP: Final[float] = 0.5
83
+ """Native microns per pixel of UNI2-h, an operating point nothing resamples to."""
84
+ REFERENCE_MPP: Final[float] = DISTILLATION_MPP
85
+ """Microns per pixel the encoder reads, which tiles of a declared resolution are brought to."""
86
+
87
+ MPP_TOLERANCE: Final[float] = 0.02
88
+ """Relative difference between a declared mpp and the target above which tiles are resampled."""
89
+
90
+ CROP_WARNING_FRACTION: Final[float] = 0.05
91
+ """Share of a field the center crop may discard before a `GeometryWarning` is raised."""
92
+
93
+ MIN_MICROSCOPE_MPP: Final[float] = 0.02
94
+ """Finest microns per pixel of a microscope image: a tenth of the 0.2 micron a lens resolves."""
95
+ MAX_MICROSCOPE_MPP: Final[float] = 20.0
96
+ """Coarsest microns per pixel of a microscope image, between a 1.25x scan and 600 dpi."""
97
+
98
+ # --- Retrieval --------------------------------------------------------------------------
99
+
100
+ DEFAULT_K: Final[int] = 30
101
+ """Reference neighbors each cell votes over."""
102
+ DEFAULT_TAU: Final[float] = 0.03
103
+ """Softmax temperature over neighbor similarities."""
104
+
105
+ RETRIEVAL_DEPTH: Final[int] = 200
106
+ """Neighbors retrieved, then scored again pair by pair, before the slice to k."""
107
+
108
+ L2_EPS: Final[float] = 1e-12
109
+ SOFTMAX_EPS: Final[float] = 1e-12
110
+ ENTROPY_EPS: Final[float] = 1e-12
111
+ POOL_EPS: Final[float] = 1e-6
112
+
113
+ # --- Class axes -------------------------------------------------------------------------
114
+
115
+ UNKNOWN_LABEL: Final[str] = "Unknown"
116
+ """Name of the unknown class, last in every class order, which no row is coded with."""
117
+
118
+ LINEAGE_V1: Final[tuple[str, ...]] = (
119
+ "B_Plasma",
120
+ "Epithelial_Melanocyte",
121
+ "Myeloid",
122
+ "Stromal_Vascular",
123
+ "T_NK",
124
+ )
125
+ """Classes of the `lineage_v1` axis, in the order of their codes (`label_order`)."""
126
+
127
+ COMPARTMENT_V1: Final[tuple[str, ...]] = (
128
+ "Epithelial_Melanocyte",
129
+ "Immune",
130
+ "Stromal_Vascular",
131
+ )
132
+ """Classes of the `compartment_v1` axis, in the order of their codes (`label_order`)."""
133
+
134
+ CLASS_AXES: Final[dict[str, tuple[str, ...]]] = {
135
+ "lineage_v1": LINEAGE_V1,
136
+ "compartment_v1": COMPARTMENT_V1,
137
+ }
138
+ """Declared class axes, by name."""
139
+
140
+ DEFAULT_AXIS: Final[str] = "lineage_v1"
141
+ """Class axis used where none is named."""
142
+
143
+ CLASS_COLORS: Final[dict[str, tuple[int, int, int]]] = {
144
+ "T_NK": (255, 15, 15),
145
+ "B_Plasma": (255, 143, 15),
146
+ "Myeloid": (255, 211, 15),
147
+ "Immune": (255, 123, 15),
148
+ "Stromal_Vascular": (113, 191, 18),
149
+ "Epithelial_Melanocyte": (39, 188, 178),
150
+ "Unknown": (143, 143, 143),
151
+ }
152
+ """RGB color of each class in the figures of `alhena.viz`."""
153
+
154
+ # --- Reference thresholds ---------------------------------------------------------------
155
+
156
+ MIN_REFERENCE_CELLS_PER_CLASS: Final[int] = 200
157
+ """Reference cells below which a class is reported as thinly supported."""
158
+
159
+ MIN_SUPPORT_TO_K_RATIO: Final[float] = 10.0
160
+ """Floor of the thinnest class's support over k, below which `EnvelopeWarning` is raised."""
161
+ MIN_K_TO_CLASSES_RATIO: Final[float] = 3.0
162
+ """Floor of k over the number of classes, below which `EnvelopeWarning` is raised."""
163
+
164
+ # --- Reference registry -----------------------------------------------------------------
165
+
166
+ TISSUE_TO_SHARD: Final[dict[str, str]] = {
167
+ "Breast": "breast",
168
+ "BoneMarrow": "bone_marrow",
169
+ "Cervix": "cervix",
170
+ "Colon": "intestine",
171
+ "Heart": "heart",
172
+ "Kidney": "kidney",
173
+ "Liver": "liver_cell",
174
+ "Lung": "lung",
175
+ "LymphNode": "tonsil",
176
+ "Ovary": "ovary",
177
+ "Pancreas": "pancreas_cell",
178
+ "Prostate": "prostate",
179
+ "Skin": "skin",
180
+ "Tonsil": "tonsil",
181
+ "Uterus": "uterus",
182
+ }
183
+ """Shard each tissue is served by, under its canonical organ name; tissues may share one."""
184
+
185
+ # Once case, spacing and separators are ignored, no alias matches a tissue, a shard or another.
186
+ TISSUE_ALIASES: Final[dict[str, str]] = {
187
+ # Adjectives of the organ, but not Cardiac or Cervical, which also name the cardia and the neck.
188
+ "Colonic": "Colon",
189
+ "Cutaneous": "Skin",
190
+ "Hepatic": "Liver",
191
+ "Mammary": "Breast",
192
+ "Ovarian": "Ovary",
193
+ "Pancreatic": "Pancreas",
194
+ "Prostatic": "Prostate",
195
+ "Pulmonary": "Lung",
196
+ "Renal": "Kidney",
197
+ "Tonsillar": "Tonsil",
198
+ "Uterine": "Uterus",
199
+ # The fuller anatomical name of the organ.
200
+ "Cervix uteri": "Cervix",
201
+ "Prostate gland": "Prostate",
202
+ "Uterine cervix": "Cervix",
203
+ # Parts or regions of the organ the atlas behind the shard samples, by the site of each cell.
204
+ "Bowel": "Colon",
205
+ "Colorectal": "Colon",
206
+ "Large intestine": "Colon",
207
+ "Rectum": "Colon",
208
+ # The intestine atlas holds ileum, cecum, appendix, colon and rectum, no duodenum or jejunum.
209
+ "Small intestine": "Colon",
210
+ "Corpus uteri": "Uterus",
211
+ "Endometrium": "Uterus",
212
+ "Myometrium": "Uterus",
213
+ }
214
+ """Other names a tissue is accepted under, each mapped to its tissue in `TISSUE_TO_SHARD`."""
215
+
216
+ SCCONCEPT_MODEL: Final[str] = "corpus40M-model30M"
217
+ SCCONCEPT_VERSION: Final[str] = "v2"
218
+
219
+
220
+ def label_order(axis: str) -> tuple[str, ...]:
221
+ """Return the class order of an axis, scorable classes sorted, then the unknown class.
222
+
223
+ The order fixes the code of every class and breaks ties in the vote.
224
+
225
+ Args:
226
+ axis: A key of `CLASS_AXES`.
227
+
228
+ Returns:
229
+ Class names in their canonical order.
230
+
231
+ Raises:
232
+ AlhenaValueError: If the axis is not one of the declared axes.
233
+ """
234
+ if axis not in CLASS_AXES:
235
+ raise AlhenaValueError(f"Unknown class axis {axis!r}. Declared axes: {sorted(CLASS_AXES)}.")
236
+ return (*sorted(CLASS_AXES[axis]), UNKNOWN_LABEL)
237
+
238
+
239
+ def _check_class_names(names: Sequence[str], error: type[AlhenaError] = AlhenaValueError) -> None:
240
+ """Refuse a class name that no key of an .h5ad or zarr file can hold."""
241
+ for name in names:
242
+ if "/" in name:
243
+ raise error(
244
+ f"The class name {name!r} holds '/', which no key of an .h5ad or zarr file can "
245
+ "hold, so its results could not be written. Rename the class, for example "
246
+ f"{name.replace('/', '_')!r}."
247
+ )
alhena/_coverage.py ADDED
@@ -0,0 +1,77 @@
1
+ """What a reference cannot express, and whether `k` suits the cells of its classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections.abc import Mapping
7
+
8
+ from alhena._constants import (
9
+ MIN_K_TO_CLASSES_RATIO,
10
+ MIN_REFERENCE_CELLS_PER_CLASS,
11
+ MIN_SUPPORT_TO_K_RATIO,
12
+ )
13
+ from alhena._errors import PACKAGE_PREFIXES, EnvelopeWarning, ReferenceCoverageWarning
14
+
15
+ THIN_SUPPORT_WARNING: str = "The thinnest class the"
16
+ """Opening of the warning that the thinnest class of a reference has too few cells for `k`."""
17
+
18
+
19
+ def _missing_classes(support: Mapping[str, int]) -> tuple[str, ...]:
20
+ """Return the classes no reference cell carries, in class order."""
21
+ return tuple(name for name, count in support.items() if count == 0)
22
+
23
+
24
+ def _thin_classes(support: Mapping[str, int]) -> tuple[str, ...]:
25
+ """Return the classes too few reference cells carry for retrieval to be reliable."""
26
+ return tuple(
27
+ name for name, count in support.items() if 0 < count < MIN_REFERENCE_CELLS_PER_CLASS
28
+ )
29
+
30
+
31
+ def _thinnest_class(support: Mapping[str, int], k: int, reference: str | None = None) -> str | None:
32
+ """Return the sentence on the thinnest class a reference expresses, or None if `k` suits it."""
33
+ present = {name: count for name, count in support.items() if count > 0}
34
+ if not present or min(present.values()) / k >= MIN_SUPPORT_TO_K_RATIO:
35
+ return None
36
+ minimum = min(present.values())
37
+ thinnest = next(name for name, count in present.items() if count == minimum)
38
+ named = "reference" if reference is None else f"{reference} reference"
39
+ return (
40
+ f"{THIN_SUPPORT_WARNING} {named} expresses, {thinnest}, has {minimum} cells, fewer than "
41
+ f"the {k * MIN_SUPPORT_TO_K_RATIO:g} recommended for k={k}. Interpret predictions of "
42
+ f"{thinnest} with caution."
43
+ )
44
+
45
+
46
+ def _warn_coverage(support: Mapping[str, int], k: int, axis: str, *, loading: bool) -> None:
47
+ """Warn of missing and thinnest classes, thin ones at load, and k over classes at annotation."""
48
+ missing = _missing_classes(support)
49
+ if missing:
50
+ warnings.warn(
51
+ f"The reference holds no cell of {', '.join(missing)} on the {axis} axis, so these "
52
+ "classes cannot be predicted with this reference. Their absence from the annotations "
53
+ "does not show that they are absent from the tissue.",
54
+ ReferenceCoverageWarning,
55
+ skip_file_prefixes=PACKAGE_PREFIXES,
56
+ )
57
+ thinly = _thin_classes(support) if loading else ()
58
+ if thinly:
59
+ counted = ", ".join(f"{name} ({support[name]})" for name in thinly)
60
+ warnings.warn(
61
+ f"The reference has fewer than {MIN_REFERENCE_CELLS_PER_CLASS} labeled cells for "
62
+ f"{counted} on the {axis} axis. Interpret predictions for each listed class with "
63
+ "caution.",
64
+ ReferenceCoverageWarning,
65
+ skip_file_prefixes=PACKAGE_PREFIXES,
66
+ )
67
+ sentence = _thinnest_class(support, k)
68
+ if sentence is not None:
69
+ warnings.warn(sentence, EnvelopeWarning, skip_file_prefixes=PACKAGE_PREFIXES)
70
+ if not loading and k / len(support) < MIN_K_TO_CLASSES_RATIO:
71
+ warnings.warn(
72
+ f"k={k} across {len(support)} classes is below the {MIN_K_TO_CLASSES_RATIO:g} "
73
+ "reference neighbors per class recommended by ALHENA. Interpret confidence values "
74
+ "with caution.",
75
+ EnvelopeWarning,
76
+ skip_file_prefixes=PACKAGE_PREFIXES,
77
+ )