structboost 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.
- structboost/__init__.py +94 -0
- structboost/_annotation.py +266 -0
- structboost/_boosting.py +552 -0
- structboost/_decoder.py +109 -0
- structboost/_encoder.py +88 -0
- structboost/_explorer.py +842 -0
- structboost/_io.py +302 -0
- structboost/_model.py +3483 -0
- structboost/_persistence.py +326 -0
- structboost/_plotting.py +301 -0
- structboost/_simulation.py +867 -0
- structboost/_stability.py +412 -0
- structboost/_types.py +393 -0
- structboost/_utils.py +509 -0
- structboost/py.typed +0 -0
- structboost-0.1.0.dist-info/METADATA +219 -0
- structboost-0.1.0.dist-info/RECORD +19 -0
- structboost-0.1.0.dist-info/WHEEL +4 -0
- structboost-0.1.0.dist-info/licenses/LICENSE +21 -0
structboost/_io.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Read and write BAE encoder weight matrices as standalone files.
|
|
2
|
+
|
|
3
|
+
An encoder weight matrix is the transferable, interpretable product of a BAE fit:
|
|
4
|
+
``(n_genes, latent_dim)`` sparse gene loadings. Shipping it to another dataset
|
|
5
|
+
requires the gene identifiers to travel with it, which a bare array cannot do.
|
|
6
|
+
|
|
7
|
+
Parquet is the recommended format. Beyond preserving dtypes and the index, it is
|
|
8
|
+
immune to the spreadsheet round-trip that silently rewrites gene symbols such as
|
|
9
|
+
``SEPT2``, ``MARCH1`` and ``DEC1`` as dates — found in roughly a fifth of papers
|
|
10
|
+
carrying Excel supplements [1]_. CSV/TSV is accepted on read for interoperability
|
|
11
|
+
but is not the recommended write format.
|
|
12
|
+
|
|
13
|
+
Two identifier columns are carried:
|
|
14
|
+
|
|
15
|
+
``gene_id``
|
|
16
|
+
Ensembl accession (``ENSG…``, ``ENSMUSG…``). Stable across annotation
|
|
17
|
+
releases and unambiguous, so it is the default join key.
|
|
18
|
+
``gene_symbol``
|
|
19
|
+
Human-readable label. Carried but never authoritative: symbols are revised
|
|
20
|
+
between releases and collide through aliases and species case conventions.
|
|
21
|
+
|
|
22
|
+
References
|
|
23
|
+
----------
|
|
24
|
+
.. [1] Ziemann, M., Eren, Y. & El-Osta, A. (2016). Gene name errors are
|
|
25
|
+
widespread in the scientific literature. *Genome Biology* 17, 177.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
from numpy.typing import NDArray
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
39
|
+
import pandas as pd
|
|
40
|
+
|
|
41
|
+
#: Ensembl gene accessions: ``ENS`` + optional species code + ``G`` + digits,
|
|
42
|
+
#: with an optional ``.version`` suffix (``ENSG00000141510.17``).
|
|
43
|
+
_ENSEMBL_GENE_RE = re.compile(r"^ENS[A-Z]{0,6}G\d{5,}(\.\d+)?$")
|
|
44
|
+
|
|
45
|
+
#: Column name prefix for the latent dimensions in the on-disk wide layout.
|
|
46
|
+
DIM_PREFIX = "dim_"
|
|
47
|
+
|
|
48
|
+
_PARQUET_SUFFIXES = frozenset({".parquet", ".pq"})
|
|
49
|
+
_TEXT_SUFFIXES = frozenset({".csv", ".tsv", ".txt"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def looks_like_ensembl(identifiers: object, *, min_fraction: float = 0.5) -> bool:
|
|
53
|
+
"""Whether a set of gene identifiers is predominantly Ensembl accessions.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
identifiers
|
|
58
|
+
Iterable of gene identifiers.
|
|
59
|
+
min_fraction
|
|
60
|
+
Fraction that must match the Ensembl pattern. Defaults to 0.5 rather than
|
|
61
|
+
1.0 because real panels carry a tail of spike-ins and custom features
|
|
62
|
+
that never match, and rejecting a whole panel over those would be wrong.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
True if at least ``min_fraction`` of the identifiers are Ensembl accessions.
|
|
67
|
+
"""
|
|
68
|
+
values = [str(value) for value in np.asarray(identifiers, dtype=object).ravel()]
|
|
69
|
+
if not values:
|
|
70
|
+
return False
|
|
71
|
+
hits = sum(1 for value in values if _ENSEMBL_GENE_RE.match(value))
|
|
72
|
+
return hits / len(values) >= min_fraction
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolve_format(path: Path, fmt: str | None) -> str:
|
|
76
|
+
if fmt is not None:
|
|
77
|
+
if fmt not in ("parquet", "text"):
|
|
78
|
+
raise ValueError(f"format must be 'parquet' or 'text', got {fmt!r}")
|
|
79
|
+
return fmt
|
|
80
|
+
suffixes = [s.lower() for s in path.suffixes]
|
|
81
|
+
# `.csv.gz` and friends: the compression suffix is not the format.
|
|
82
|
+
meaningful = [s for s in suffixes if s not in (".gz", ".bz2", ".xz", ".zst")]
|
|
83
|
+
suffix = meaningful[-1] if meaningful else ""
|
|
84
|
+
if suffix in _PARQUET_SUFFIXES:
|
|
85
|
+
return "parquet"
|
|
86
|
+
if suffix in _TEXT_SUFFIXES:
|
|
87
|
+
return "text"
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"Cannot infer format from {path.name!r}. Expected one of "
|
|
90
|
+
f"{sorted(_PARQUET_SUFFIXES | _TEXT_SUFFIXES)}, or pass format= explicitly."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _sidecar_path(path: Path) -> Path:
|
|
95
|
+
return path.with_suffix(path.suffix + ".meta.json")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_encoder_weights(
|
|
99
|
+
weights: NDArray[np.floating] | pd.DataFrame,
|
|
100
|
+
path: str | Path,
|
|
101
|
+
*,
|
|
102
|
+
gene_ids: object | None = None,
|
|
103
|
+
gene_symbols: object | None = None,
|
|
104
|
+
metadata: dict[str, Any] | None = None,
|
|
105
|
+
drop_zero_rows: bool = False,
|
|
106
|
+
format: str | None = None,
|
|
107
|
+
) -> Path:
|
|
108
|
+
"""Write an encoder weight matrix with its gene identifiers.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
weights
|
|
113
|
+
Array of shape ``(n_genes, latent_dim)``, or a DataFrame indexed by gene
|
|
114
|
+
identifier whose remaining columns are the latent dimensions.
|
|
115
|
+
path
|
|
116
|
+
Destination. Format is inferred from the suffix unless ``format`` is given.
|
|
117
|
+
gene_ids
|
|
118
|
+
Ensembl accessions, length ``n_genes``. The join key on read.
|
|
119
|
+
gene_symbols
|
|
120
|
+
Gene symbols, length ``n_genes``. Carried as a label only.
|
|
121
|
+
metadata
|
|
122
|
+
Provenance recorded with the matrix. ``structboost_version`` and
|
|
123
|
+
``latent_dim`` are filled in automatically. Callers should add the
|
|
124
|
+
species and **annotation release**: without the release the Ensembl
|
|
125
|
+
accessions are only probably joinable to another dataset.
|
|
126
|
+
drop_zero_rows
|
|
127
|
+
Omit genes whose weights are zero in every dimension. Safe for the
|
|
128
|
+
coverage guard (a zero weight contributes nothing to either side of the
|
|
129
|
+
ratio), but the file then no longer distinguishes the reference *panel*
|
|
130
|
+
from the reference *support*, so the full panel is stored in the metadata.
|
|
131
|
+
format
|
|
132
|
+
``"parquet"`` or ``"text"``. Inferred from the suffix when omitted.
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
The path written.
|
|
137
|
+
"""
|
|
138
|
+
import pandas as pd
|
|
139
|
+
|
|
140
|
+
path = Path(path)
|
|
141
|
+
resolved = _resolve_format(path, format)
|
|
142
|
+
|
|
143
|
+
if isinstance(weights, pd.DataFrame):
|
|
144
|
+
frame = weights.copy()
|
|
145
|
+
if gene_ids is None and frame.index.name in ("gene_id", None):
|
|
146
|
+
gene_ids = frame.index.to_numpy()
|
|
147
|
+
matrix = frame.to_numpy(dtype=np.float64)
|
|
148
|
+
else:
|
|
149
|
+
matrix = np.asarray(weights, dtype=np.float64)
|
|
150
|
+
|
|
151
|
+
if matrix.ndim != 2:
|
|
152
|
+
raise ValueError(f"weights must be 2-D (n_genes, latent_dim), got {matrix.shape}")
|
|
153
|
+
n_genes, latent_dim = matrix.shape
|
|
154
|
+
|
|
155
|
+
def _check(name: str, values: object) -> NDArray[np.object_] | None:
|
|
156
|
+
if values is None:
|
|
157
|
+
return None
|
|
158
|
+
array = np.asarray(values, dtype=object).ravel()
|
|
159
|
+
if array.shape[0] != n_genes:
|
|
160
|
+
raise ValueError(f"{name} has length {array.shape[0]} but weights has {n_genes} rows")
|
|
161
|
+
return array
|
|
162
|
+
|
|
163
|
+
ids = _check("gene_ids", gene_ids)
|
|
164
|
+
symbols = _check("gene_symbols", gene_symbols)
|
|
165
|
+
if ids is None and symbols is None:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"At least one of gene_ids or gene_symbols is required. An encoder "
|
|
168
|
+
"weight matrix without gene identifiers cannot be aligned to another "
|
|
169
|
+
"dataset, which is the only reason to write one out."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
full_panel = [str(v) for v in (ids if ids is not None else symbols)]
|
|
173
|
+
|
|
174
|
+
columns: dict[str, Any] = {}
|
|
175
|
+
if ids is not None:
|
|
176
|
+
columns["gene_id"] = [str(v) for v in ids]
|
|
177
|
+
if symbols is not None:
|
|
178
|
+
columns["gene_symbol"] = [str(v) for v in symbols]
|
|
179
|
+
for j in range(latent_dim):
|
|
180
|
+
columns[f"{DIM_PREFIX}{j}"] = matrix[:, j]
|
|
181
|
+
frame = pd.DataFrame(columns)
|
|
182
|
+
|
|
183
|
+
meta: dict[str, Any] = dict(metadata or {})
|
|
184
|
+
meta.setdefault("latent_dim", int(latent_dim))
|
|
185
|
+
meta.setdefault("n_genes_written", int(n_genes))
|
|
186
|
+
try:
|
|
187
|
+
from . import __version__
|
|
188
|
+
|
|
189
|
+
meta.setdefault("structboost_version", __version__)
|
|
190
|
+
except Exception: # noqa: BLE001 - provenance is best-effort, never fatal
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
if drop_zero_rows:
|
|
194
|
+
keep = (matrix != 0).any(axis=1)
|
|
195
|
+
frame = frame.loc[keep].reset_index(drop=True)
|
|
196
|
+
meta["reference_panel"] = full_panel
|
|
197
|
+
meta["n_genes_written"] = int(keep.sum())
|
|
198
|
+
|
|
199
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
if resolved == "parquet":
|
|
201
|
+
_write_parquet(frame, path, meta)
|
|
202
|
+
else:
|
|
203
|
+
sep = "\t" if ".tsv" in [s.lower() for s in path.suffixes] else ","
|
|
204
|
+
frame.to_csv(path, index=False, sep=sep)
|
|
205
|
+
_sidecar_path(path).write_text(json.dumps(meta, indent=2, default=str))
|
|
206
|
+
return path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_parquet(frame: pd.DataFrame, path: Path, meta: dict[str, Any]) -> None:
|
|
210
|
+
try:
|
|
211
|
+
import pyarrow as pa
|
|
212
|
+
import pyarrow.parquet as pq
|
|
213
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
214
|
+
raise ImportError(
|
|
215
|
+
"Writing Parquet needs pyarrow. Install it with "
|
|
216
|
+
"`pip install 'structboost[io]'`, or write a .csv instead."
|
|
217
|
+
) from exc
|
|
218
|
+
|
|
219
|
+
table = pa.Table.from_pandas(frame, preserve_index=False)
|
|
220
|
+
existing = table.schema.metadata or {}
|
|
221
|
+
encoded = {
|
|
222
|
+
**existing,
|
|
223
|
+
b"structboost": json.dumps(meta, default=str).encode("utf-8"),
|
|
224
|
+
}
|
|
225
|
+
pq.write_table(table.replace_schema_metadata(encoded), path)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_encoder_weights(
|
|
229
|
+
path: str | Path,
|
|
230
|
+
*,
|
|
231
|
+
format: str | None = None,
|
|
232
|
+
) -> pd.DataFrame:
|
|
233
|
+
"""Read an encoder weight matrix written by :func:`structboost.write_encoder_weights`.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
path
|
|
238
|
+
Source file. Format is inferred from the suffix unless ``format`` is given.
|
|
239
|
+
format
|
|
240
|
+
``"parquet"`` or ``"text"``. Inferred from the suffix when omitted.
|
|
241
|
+
|
|
242
|
+
Returns
|
|
243
|
+
-------
|
|
244
|
+
DataFrame of shape ``(n_genes, latent_dim)`` indexed by the join identifier —
|
|
245
|
+
``gene_id`` when the file carries Ensembl accessions, otherwise
|
|
246
|
+
``gene_symbol``. Any symbol column is preserved in ``frame.attrs["gene_symbol"]``
|
|
247
|
+
rather than as a data column, so every remaining column is a latent dimension.
|
|
248
|
+
Provenance is available in ``frame.attrs["metadata"]`` and the join key used in
|
|
249
|
+
``frame.attrs["join_key"]``.
|
|
250
|
+
"""
|
|
251
|
+
import pandas as pd
|
|
252
|
+
|
|
253
|
+
path = Path(path)
|
|
254
|
+
if not path.exists():
|
|
255
|
+
raise FileNotFoundError(f"No encoder weight file at {path}")
|
|
256
|
+
resolved = _resolve_format(path, format)
|
|
257
|
+
|
|
258
|
+
meta: dict[str, Any] = {}
|
|
259
|
+
if resolved == "parquet":
|
|
260
|
+
try:
|
|
261
|
+
import pyarrow.parquet as pq
|
|
262
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
263
|
+
raise ImportError(
|
|
264
|
+
"Reading Parquet needs pyarrow. Install it with `pip install 'structboost[io]'`."
|
|
265
|
+
) from exc
|
|
266
|
+
table = pq.read_table(path)
|
|
267
|
+
raw = (table.schema.metadata or {}).get(b"structboost")
|
|
268
|
+
if raw is not None:
|
|
269
|
+
meta = json.loads(raw.decode("utf-8"))
|
|
270
|
+
frame = table.to_pandas()
|
|
271
|
+
else:
|
|
272
|
+
sep = "\t" if ".tsv" in [s.lower() for s in path.suffixes] else ","
|
|
273
|
+
frame = pd.read_csv(path, sep=sep)
|
|
274
|
+
sidecar = _sidecar_path(path)
|
|
275
|
+
if sidecar.exists():
|
|
276
|
+
meta = json.loads(sidecar.read_text())
|
|
277
|
+
|
|
278
|
+
dim_columns = [c for c in frame.columns if str(c).startswith(DIM_PREFIX)]
|
|
279
|
+
if not dim_columns:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
f"{path.name} has no latent-dimension columns (expected names starting "
|
|
282
|
+
f"with {DIM_PREFIX!r}). Columns found: {list(frame.columns)}"
|
|
283
|
+
)
|
|
284
|
+
# Sort numerically: dim_10 must not order before dim_2.
|
|
285
|
+
dim_columns.sort(key=lambda c: int(str(c)[len(DIM_PREFIX) :]))
|
|
286
|
+
|
|
287
|
+
has_id = "gene_id" in frame.columns
|
|
288
|
+
has_symbol = "gene_symbol" in frame.columns
|
|
289
|
+
if not has_id and not has_symbol:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"{path.name} carries no gene_id or gene_symbol column, so its weights "
|
|
292
|
+
"cannot be aligned to a dataset."
|
|
293
|
+
)
|
|
294
|
+
join_key = "gene_id" if has_id else "gene_symbol"
|
|
295
|
+
|
|
296
|
+
out = frame[dim_columns].astype(np.float64)
|
|
297
|
+
out.index = pd.Index(frame[join_key].astype(str), name=join_key)
|
|
298
|
+
out.attrs["metadata"] = meta
|
|
299
|
+
out.attrs["join_key"] = join_key
|
|
300
|
+
if has_symbol:
|
|
301
|
+
out.attrs["gene_symbol"] = frame["gene_symbol"].astype(str).to_numpy()
|
|
302
|
+
return out
|