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/_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