rafinat 0.2.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.
rafinat-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: rafinat
3
+ Version: 0.2.0
4
+ Summary: Bayesian normalizations for RNA-seq
5
+ Author-email: Georgy Meshcheryakov <iam@georgy.top>
6
+ License: BSD-2-Clause
7
+ Keywords: single-cell,scRNA-seq,RNA-seq,normalization,bioinformatics,Dirichlet-multinomial,compositional data,CLR,Fisher-Rao,scikit-learn
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: License :: OSI Approved :: BSD License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: numpy
20
+ Requires-Dist: scipy
21
+ Provides-Extra: rafinat
22
+ Requires-Dist: liudist>=0.1; extra == "rafinat"
23
+ Provides-Extra: gpu
24
+ Requires-Dist: liudist>=0.1; extra == "gpu"
25
+ Provides-Extra: sklearn
26
+ Requires-Dist: scikit-learn; extra == "sklearn"
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest; extra == "test"
29
+ Requires-Dist: scikit-learn; extra == "test"
30
+
31
+ # rafinat
32
+
33
+ Dirichlet-Multinomial posterior normalizations for single-cell RNA-seq count data.
34
+
35
+ Three count normalizations derived from a Dirichlet-Multinomial (Liouville) posterior view of the
36
+ latent expression rates.
37
+
38
+ Each is a scikit-learn-style transformer: you instantiate it with its operating point, then
39
+ `.fit` / `.transform` / `.fit_transform` a count matrix — so it drops into a preprocessing pipeline.
40
+
41
+ | transformer | what it is | default operating point |
42
+ |----------|-----------|--------------------------|
43
+ | `compost` | the DM-posterior generalisation of CLR | `p=0` digamma log-corner, top-2/3-trimmed per-cell centering |
44
+ | `hellnorm` | the Hellinger / Fisher-Rao spherical (sqrt-CLR) normalization | top-2/3-trimmed per-cell sqrt reference |
45
+ | `rafinat` | the complete Fisher-Rao Liouville embedding | composition arc length (sharp `r_snr`) + radial cell-size coordinate, `beta="total"` |
46
+
47
+ All take a dense `(genes, cells)` array of raw counts (genes = features/rows, cells =
48
+ samples/columns) and return `(genes, cells)` (`rafinat` returns `(genes + 1, cells)` — the extra
49
+ row is the cell-size scale coordinate). `compost` and `hellnorm` are pure `numpy`/`scipy` (CPU).
50
+ `rafinat` uses the optional [`liudist`](liudist/) package (Fisher-Rao geometry of Liouville laws;
51
+ pulls in JAX, and benefits from a GPU JAX build) — installed via the `[rafinat]` extra.
52
+
53
+ ```python
54
+ import numpy as np, rafinat
55
+ X = np.random.poisson(0.5, size=(2000, 500)).astype(float) # genes x cells
56
+
57
+ Z = rafinat.compost().fit_transform(X) # CLR-dominating default (compost p=0, trimmed)
58
+ H = rafinat.hellnorm().fit_transform(X) # Hellnorm, trimmed reference
59
+ R = rafinat.rafinat().fit_transform(X) # rafinat (complete); (genes + 1) x cells
60
+ ```
61
+
62
+ ### fit / transform
63
+
64
+ `fit` estimates and **freezes** the two data-driven pieces — the concentration `r` and the per-cell
65
+ reference level `beta` — and `transform` re-applies that frozen normalization. Because `beta` is
66
+ per-cell, `transform` expects a matrix of the same `(genes, cells)` shape it was fitted on (re-fit
67
+ for a different gene/cell set). `fit_transform(X)` is the one-shot form.
68
+
69
+ ```python
70
+ tr = rafinat.compost(p=0.5).fit(X) # estimate & freeze tr.r_ and tr.beta_
71
+ Z = tr.transform(X) # apply; == tr.fit_transform(X)
72
+
73
+ from sklearn.pipeline import Pipeline # optional — also works without scikit-learn installed
74
+ pipe = Pipeline([("normalize", rafinat.compost())])
75
+ Z = pipe.fit_transform(X)
76
+ ```
77
+
78
+ scikit-learn is an **optional** extra: if installed, the transformers inherit
79
+ `BaseEstimator` / `TransformerMixin` (full `Pipeline` / `clone` / `get_params` support); otherwise a
80
+ light built-in shim provides the same `fit` / `transform` / `fit_transform` / `get_params` API.
81
+
82
+ ## Choosing the operating point
83
+
84
+ The benchmark-winning defaults are baked in, but every knob is a constructor argument:
85
+
86
+ ```python
87
+ rafinat.compost(p=0.0) # default: digamma log-corner, trimmed centering
88
+ rafinat.compost(p=0.5) # posterior sqrt mean (order-1/2 power-mean cell size)
89
+ rafinat.compost(trim=0.0) # ordinary (non-trimmed) per-cell centering
90
+ rafinat.compost(r="mle") # pooled DM-MLE concentration instead of the CLR-matched r
91
+ rafinat.compost(zscore=True) # + per-gene z-score (the optional '->Z' standardization)
92
+
93
+ rafinat.hellnorm(reference="uniform") # classic log-map references: uniform / extrinsic / frechet
94
+ rafinat.hellnorm(trim=0.5) # lighter top-trim
95
+
96
+ rafinat.rafinat(beta="atop10") # simulation-leaning cell-size estimator
97
+ rafinat.rafinat(r_comp="mle") # smoother directional concentration
98
+ rafinat.rafinat(zcomp=True) # + per-gene z-score of the composition rows ('-> coordZ')
99
+ ```
100
+
101
+ Each of these returns a transformer; call `.fit_transform(X)` (or `.fit(X)` then `.transform(X)`)
102
+ on it. After fitting, the estimated values are exposed as fitted attributes (trailing underscore):
103
+ `compost.r_` / `compost.beta_`, `rafinat.r_comp_` / `rafinat.ref_` / `rafinat.C_` /
104
+ `rafinat.beta_`.
105
+
106
+ **The optional per-gene standardization (`compost(zscore=True)`, `rafinat(zcomp=True)`) is disabled
107
+ by default**, matching the benchmark's shipped defaults.
108
+
109
+ ## Install
110
+
111
+ ```bash
112
+ pip install rafinat # compost / hellnorm (numpy + scipy only)
113
+ pip install "rafinat[rafinat]" # + the rafinat() method — adds the liudist backend (pulls in JAX)
114
+ ```
115
+
116
+ `compost` and `hellnorm` need only `numpy`/`scipy`; the heavy `liudist` + JAX stack is pulled in
117
+ **only** by the `[rafinat]` extra, i.e. only if you use the `rafinat()` method.
118
+
119
+
120
+
121
+ ## Method provenance
122
+
123
+ - **compost** — `digamma(r + x)` with `r = 1/(4·alpha) + 1/2` (so `digamma(r+x) ≈ log(x + 1/(4·alpha))`), minus a per-cell location estimated on the **low-expression bulk**
124
+ (the top high-expression genes — the biologically variable ones — are dropped from the cell-size
125
+ estimate).
126
+ - **hellnorm** — `sqrt(x / sum x)` minus a top-trimmed per-cell mean (the sqrt-geometry analog of
127
+ compost's trimmed centering). Dominates the classic uniform/extrinsic/Frechet references.
128
+ - **rafinat** — the Fisher-Rao Liouville distance factors as
129
+ `d² = d_composition² + C·(d ln β)²`; rafinat realises it as the stack
130
+ `[ composition arc length (depth-normalized, sharp r_snr) ; sqrt(C)·ln(β_c) ]`, with the radial
131
+ weight `C = genes · r_mle` decoupled from the (sharp) directional concentration so the cell-size
132
+ axis stays alive at a parameter-free weight.
@@ -0,0 +1,102 @@
1
+ # rafinat
2
+
3
+ Dirichlet-Multinomial posterior normalizations for single-cell RNA-seq count data.
4
+
5
+ Three count normalizations derived from a Dirichlet-Multinomial (Liouville) posterior view of the
6
+ latent expression rates.
7
+
8
+ Each is a scikit-learn-style transformer: you instantiate it with its operating point, then
9
+ `.fit` / `.transform` / `.fit_transform` a count matrix — so it drops into a preprocessing pipeline.
10
+
11
+ | transformer | what it is | default operating point |
12
+ |----------|-----------|--------------------------|
13
+ | `compost` | the DM-posterior generalisation of CLR | `p=0` digamma log-corner, top-2/3-trimmed per-cell centering |
14
+ | `hellnorm` | the Hellinger / Fisher-Rao spherical (sqrt-CLR) normalization | top-2/3-trimmed per-cell sqrt reference |
15
+ | `rafinat` | the complete Fisher-Rao Liouville embedding | composition arc length (sharp `r_snr`) + radial cell-size coordinate, `beta="total"` |
16
+
17
+ All take a dense `(genes, cells)` array of raw counts (genes = features/rows, cells =
18
+ samples/columns) and return `(genes, cells)` (`rafinat` returns `(genes + 1, cells)` — the extra
19
+ row is the cell-size scale coordinate). `compost` and `hellnorm` are pure `numpy`/`scipy` (CPU).
20
+ `rafinat` uses the optional [`liudist`](liudist/) package (Fisher-Rao geometry of Liouville laws;
21
+ pulls in JAX, and benefits from a GPU JAX build) — installed via the `[rafinat]` extra.
22
+
23
+ ```python
24
+ import numpy as np, rafinat
25
+ X = np.random.poisson(0.5, size=(2000, 500)).astype(float) # genes x cells
26
+
27
+ Z = rafinat.compost().fit_transform(X) # CLR-dominating default (compost p=0, trimmed)
28
+ H = rafinat.hellnorm().fit_transform(X) # Hellnorm, trimmed reference
29
+ R = rafinat.rafinat().fit_transform(X) # rafinat (complete); (genes + 1) x cells
30
+ ```
31
+
32
+ ### fit / transform
33
+
34
+ `fit` estimates and **freezes** the two data-driven pieces — the concentration `r` and the per-cell
35
+ reference level `beta` — and `transform` re-applies that frozen normalization. Because `beta` is
36
+ per-cell, `transform` expects a matrix of the same `(genes, cells)` shape it was fitted on (re-fit
37
+ for a different gene/cell set). `fit_transform(X)` is the one-shot form.
38
+
39
+ ```python
40
+ tr = rafinat.compost(p=0.5).fit(X) # estimate & freeze tr.r_ and tr.beta_
41
+ Z = tr.transform(X) # apply; == tr.fit_transform(X)
42
+
43
+ from sklearn.pipeline import Pipeline # optional — also works without scikit-learn installed
44
+ pipe = Pipeline([("normalize", rafinat.compost())])
45
+ Z = pipe.fit_transform(X)
46
+ ```
47
+
48
+ scikit-learn is an **optional** extra: if installed, the transformers inherit
49
+ `BaseEstimator` / `TransformerMixin` (full `Pipeline` / `clone` / `get_params` support); otherwise a
50
+ light built-in shim provides the same `fit` / `transform` / `fit_transform` / `get_params` API.
51
+
52
+ ## Choosing the operating point
53
+
54
+ The benchmark-winning defaults are baked in, but every knob is a constructor argument:
55
+
56
+ ```python
57
+ rafinat.compost(p=0.0) # default: digamma log-corner, trimmed centering
58
+ rafinat.compost(p=0.5) # posterior sqrt mean (order-1/2 power-mean cell size)
59
+ rafinat.compost(trim=0.0) # ordinary (non-trimmed) per-cell centering
60
+ rafinat.compost(r="mle") # pooled DM-MLE concentration instead of the CLR-matched r
61
+ rafinat.compost(zscore=True) # + per-gene z-score (the optional '->Z' standardization)
62
+
63
+ rafinat.hellnorm(reference="uniform") # classic log-map references: uniform / extrinsic / frechet
64
+ rafinat.hellnorm(trim=0.5) # lighter top-trim
65
+
66
+ rafinat.rafinat(beta="atop10") # simulation-leaning cell-size estimator
67
+ rafinat.rafinat(r_comp="mle") # smoother directional concentration
68
+ rafinat.rafinat(zcomp=True) # + per-gene z-score of the composition rows ('-> coordZ')
69
+ ```
70
+
71
+ Each of these returns a transformer; call `.fit_transform(X)` (or `.fit(X)` then `.transform(X)`)
72
+ on it. After fitting, the estimated values are exposed as fitted attributes (trailing underscore):
73
+ `compost.r_` / `compost.beta_`, `rafinat.r_comp_` / `rafinat.ref_` / `rafinat.C_` /
74
+ `rafinat.beta_`.
75
+
76
+ **The optional per-gene standardization (`compost(zscore=True)`, `rafinat(zcomp=True)`) is disabled
77
+ by default**, matching the benchmark's shipped defaults.
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ pip install rafinat # compost / hellnorm (numpy + scipy only)
83
+ pip install "rafinat[rafinat]" # + the rafinat() method — adds the liudist backend (pulls in JAX)
84
+ ```
85
+
86
+ `compost` and `hellnorm` need only `numpy`/`scipy`; the heavy `liudist` + JAX stack is pulled in
87
+ **only** by the `[rafinat]` extra, i.e. only if you use the `rafinat()` method.
88
+
89
+
90
+
91
+ ## Method provenance
92
+
93
+ - **compost** — `digamma(r + x)` with `r = 1/(4·alpha) + 1/2` (so `digamma(r+x) ≈ log(x + 1/(4·alpha))`), minus a per-cell location estimated on the **low-expression bulk**
94
+ (the top high-expression genes — the biologically variable ones — are dropped from the cell-size
95
+ estimate).
96
+ - **hellnorm** — `sqrt(x / sum x)` minus a top-trimmed per-cell mean (the sqrt-geometry analog of
97
+ compost's trimmed centering). Dominates the classic uniform/extrinsic/Frechet references.
98
+ - **rafinat** — the Fisher-Rao Liouville distance factors as
99
+ `d² = d_composition² + C·(d ln β)²`; rafinat realises it as the stack
100
+ `[ composition arc length (depth-normalized, sharp r_snr) ; sqrt(C)·ln(β_c) ]`, with the radial
101
+ weight `C = genes · r_mle` decoupled from the (sharp) directional concentration so the cell-size
102
+ axis stays alive at a parameter-free weight.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rafinat"
7
+ version = "0.2.0"
8
+ description = "Bayesian normalizations for RNA-seq"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "BSD-2-Clause" }
12
+ authors = [{ name = "Georgy Meshcheryakov", email = "iam@georgy.top" }]
13
+ keywords = [
14
+ "single-cell", "scRNA-seq", "RNA-seq", "normalization", "bioinformatics",
15
+ "Dirichlet-multinomial", "compositional data", "CLR", "Fisher-Rao", "scikit-learn",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: BSD License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
27
+ ]
28
+ dependencies = [
29
+ "numpy",
30
+ "scipy",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ # The rafinat() Fisher-Rao Liouville embedding needs the `liudist` backend, which pulls in JAX.
35
+ # compost() / hellnorm() are pure numpy+scipy -- installing plain `rafinat` needs none of this.
36
+ # pip install "rafinat[rafinat]" # adds liudist (+ JAX) for the rafinat() method
37
+ # For GPU acceleration, additionally install a CUDA JAX build yourself, e.g. `pip install jax-cuda12`.
38
+ rafinat = ["liudist>=0.1"]
39
+ gpu = ["liudist>=0.1"] # alias for the above
40
+ # Optional: inherit sklearn's BaseEstimator/TransformerMixin for full Pipeline/clone support.
41
+ # The transformers work without it (a light built-in shim provides the same fit/transform API).
42
+ sklearn = ["scikit-learn"]
43
+ test = ["pytest", "scikit-learn"]
44
+
45
+ # [project.urls] # fill in before uploading, if you have a repo/homepage
46
+ # Homepage = "https://github.com/<you>/rafinat"
47
+ # Repository = "https://github.com/<you>/rafinat"
48
+
49
+ [tool.setuptools]
50
+ packages = ["rafinat"]
51
+ license-files = ["LICENSE"]
@@ -0,0 +1,49 @@
1
+ """rafinat -- Dirichlet-Multinomial posterior normalizations for single-cell RNA-seq.
2
+
3
+ Three single-cell count normalizations derived from a Dirichlet-Multinomial (Liouville)
4
+ posterior view, with defaults set to the top performers of the Ahlmann-Eltze & Huber kNN
5
+ benchmark. Each is a scikit-learn-style transformer (``fit`` / ``transform`` / ``fit_transform``)
6
+ so it slots straight into data-transformation / preprocessing pipelines:
7
+
8
+ compost -- the DM-posterior generalisation of CLR (digamma log-corner with a top-trimmed
9
+ per-cell centering). ``p`` slides toward the sqrt/Hellinger end.
10
+ hellnorm -- the Hellinger / Fisher-Rao spherical (sqrt-CLR) normalization with a top-trimmed
11
+ per-cell reference.
12
+ rafinat -- the complete Fisher-Rao Liouville embedding (composition arc length + radial
13
+ cell-size coordinate). Needs the ``liudist`` GPU submodule.
14
+
15
+ ``compost`` and ``hellnorm`` are pure numpy/scipy (CPU). ``rafinat`` uses the bundled ``liudist``
16
+ git submodule, which requires a GPU JAX build.
17
+
18
+ You instantiate a transformer with its operating point, then ``fit`` it (which estimates and
19
+ FREEZES the two data-driven pieces -- the concentration ``r`` and the per-cell reference level
20
+ ``beta``) and ``transform`` with it. Because ``beta`` is per-cell, ``transform`` re-applies the
21
+ frozen normalization to a matrix of the same ``(genes, cells)`` shape. ``fit_transform(X)``
22
+ reproduces the one-shot output of the original functional API. scikit-learn is optional: if it is
23
+ installed the transformers inherit ``BaseEstimator`` / ``TransformerMixin`` (full ``Pipeline`` /
24
+ ``clone`` support), otherwise a light built-in shim provides the same API.
25
+
26
+ The optional per-gene standardization (``compost(..., zscore=True)`` and
27
+ ``rafinat(..., zcomp=True)``) is DISABLED by default, matching the benchmark's shipped defaults.
28
+
29
+ >>> import numpy as np, rafinat
30
+ >>> X = np.random.poisson(0.5, size=(2000, 500)).astype(float) # genes x cells
31
+ >>> Z = rafinat.compost().fit_transform(X) # = compost (p=0) trimmed (CLR-dominating default)
32
+ >>> H = rafinat.hellnorm().fit_transform(X) # = Hellnorm trimmed
33
+ >>> R = rafinat.rafinat().fit_transform(X) # = rafinat (complete); (genes + 1) x cells
34
+ >>>
35
+ >>> tr = rafinat.compost(p=0.5).fit(X) # estimate & freeze r_ and beta_ once
36
+ >>> Z2 = tr.transform(X) # re-apply the frozen normalization
37
+ """
38
+ from ._dm import dm_power, estimate_r_pooled, estimate_r_percell, posterior_power_moments, alpha_global
39
+ from ._base import BaseNormalizer
40
+ from .compost import compost, hellnorm, z_score_rows # compost_bend stays in rafinat.compost (experimental)
41
+ from .liou import rafinat, fisher_rao_arclength, resolve_r, depthnorm
42
+
43
+ __all__ = [
44
+ "compost", "hellnorm", "rafinat",
45
+ "BaseNormalizer",
46
+ "fisher_rao_arclength", "z_score_rows", "resolve_r", "depthnorm",
47
+ "dm_power", "estimate_r_pooled", "estimate_r_percell", "posterior_power_moments", "alpha_global",
48
+ ]
49
+ __version__ = "0.2.0"
@@ -0,0 +1,104 @@
1
+ """Scikit-learn-compatible transformer base for the rafinat normalizers.
2
+
3
+ If scikit-learn is installed, the transformers inherit its ``BaseEstimator`` /
4
+ ``TransformerMixin`` -- so they drop straight into ``sklearn.pipeline.Pipeline`` and support
5
+ ``clone`` / ``get_params`` / ``set_params``. Otherwise a light shim provides the same
6
+ ``get_params`` / ``set_params`` / ``fit_transform`` protocol, keeping the package's numpy +
7
+ scipy-only footprint intact (scikit-learn is an optional extra, never a hard dependency).
8
+
9
+ Data convention (unchanged from the old functional API): every transformer operates on a dense
10
+ ``(genes, cells)`` matrix -- genes are the features (rows), cells the samples (columns).
11
+ ``fit`` estimates and FREEZES the two data-driven pieces (the concentration ``r`` and the
12
+ per-cell reference level ``beta``); ``transform`` re-applies that exact frozen normalization and
13
+ therefore expects an array of the same ``(genes, cells)`` shape it was fitted on. The one-shot
14
+ ``fit_transform(X)`` reproduces the output of the original module-level functions.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+
20
+ import numpy as np
21
+
22
+ try: # optional: full sklearn integration when available
23
+ from sklearn.base import BaseEstimator as _SkBase, TransformerMixin as _SkMixin
24
+ _HAS_SKLEARN = True
25
+ except Exception: # pragma: no cover - sklearn is an optional extra
26
+ _HAS_SKLEARN = False
27
+
28
+
29
+ class _ShimBase:
30
+ """Minimal ``BaseEstimator`` / ``TransformerMixin`` stand-in used when sklearn is absent.
31
+
32
+ Implements the same duck-typed protocol sklearn pipelines rely on: parameters are read off
33
+ the ``__init__`` signature (so every constructor argument must be stored verbatim on the
34
+ instance), and ``fit_transform`` is ``fit(...).transform(...)``.
35
+ """
36
+
37
+ @classmethod
38
+ def _param_names(cls):
39
+ init = cls.__init__
40
+ if init is object.__init__:
41
+ return []
42
+ names = []
43
+ for name, p in inspect.signature(init).parameters.items():
44
+ if name == "self":
45
+ continue
46
+ if p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
47
+ raise RuntimeError(
48
+ f"{cls.__name__}.__init__ may not use *args/**kwargs (scikit-learn convention)")
49
+ names.append(name)
50
+ return sorted(names)
51
+
52
+ def get_params(self, deep=True):
53
+ return {k: getattr(self, k) for k in self._param_names()}
54
+
55
+ def set_params(self, **params):
56
+ valid = self._param_names()
57
+ for k, v in params.items():
58
+ if k not in valid:
59
+ raise ValueError(
60
+ f"invalid parameter {k!r} for estimator {type(self).__name__}; "
61
+ f"valid parameters are {valid}")
62
+ setattr(self, k, v)
63
+ return self
64
+
65
+ def fit_transform(self, X, y=None, **fit_params):
66
+ return self.fit(X, y, **fit_params).transform(X)
67
+
68
+ def __repr__(self):
69
+ args = ", ".join(f"{k}={getattr(self, k, None)!r}" for k in self._param_names())
70
+ return f"{type(self).__name__}({args})"
71
+
72
+
73
+ if _HAS_SKLEARN:
74
+ _Base, _Mixin = _SkBase, _SkMixin
75
+ else:
76
+ _Base, _Mixin = _ShimBase, object
77
+
78
+
79
+ class BaseNormalizer(_Base, _Mixin):
80
+ """Common fit/transform plumbing shared by :class:`compost`, :class:`hellnorm`, :class:`rafinat`."""
81
+
82
+ def _check_2d(self, X):
83
+ X = np.asarray(X, dtype=np.float64)
84
+ if X.ndim != 2:
85
+ raise ValueError(
86
+ f"{type(self).__name__} expects a 2-D (genes, cells) array, got ndim={X.ndim}")
87
+ return X
88
+
89
+ def _record_shape(self, X):
90
+ self.n_genes_, self.n_cells_ = X.shape
91
+
92
+ def _check_fitted(self):
93
+ if not hasattr(self, "n_cells_"):
94
+ raise RuntimeError(
95
+ f"This {type(self).__name__} instance is not fitted yet; call .fit(X) "
96
+ f"(or .fit_transform(X)) before .transform(X).")
97
+
98
+ def _check_transform_shape(self, X):
99
+ exp = (self.n_genes_, self.n_cells_)
100
+ if X.shape != exp:
101
+ raise ValueError(
102
+ f"{type(self).__name__}.transform received an array of shape {X.shape}, but the "
103
+ f"transformer was fitted on {exp} (genes, cells). The frozen concentration and "
104
+ f"per-cell reference require the same shape; re-fit for a different gene/cell set.")