skb-arrow 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.
- skb_arrow/__init__.py +0 -0
- skb_arrow/capabilities/__init__.py +0 -0
- skb_arrow/capabilities/_tables.py +351 -0
- skb_arrow/capabilities/ancombc.py +159 -0
- skb_arrow/capabilities/echo.py +8 -0
- skb_arrow/capabilities/mantel.py +38 -0
- skb_arrow/cli.py +32 -0
- skb_arrow/doctor.py +191 -0
- skb_arrow/errors.py +146 -0
- skb_arrow/host.py +270 -0
- skb_arrow/protocol.py +175 -0
- skb_arrow/registry.py +176 -0
- skb_arrow/transport.py +161 -0
- skb_arrow-0.1.0.dist-info/METADATA +109 -0
- skb_arrow-0.1.0.dist-info/RECORD +18 -0
- skb_arrow-0.1.0.dist-info/WHEEL +4 -0
- skb_arrow-0.1.0.dist-info/entry_points.txt +2 -0
- skb_arrow-0.1.0.dist-info/licenses/LICENSE +32 -0
skb_arrow/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Input-table contracts shared by capabilities (docs/capabilities.md#input-tables)."""
|
|
2
|
+
|
|
3
|
+
import bisect
|
|
4
|
+
import itertools
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Iterable, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pyarrow as pa
|
|
13
|
+
import pyarrow.compute as pc
|
|
14
|
+
|
|
15
|
+
from skb_arrow.errors import InvalidInput
|
|
16
|
+
|
|
17
|
+
_COLUMNS = ["sample_id", "feature_id", "value"]
|
|
18
|
+
_DISTANCE_COLUMNS = ["id_a", "id_b", "distance"]
|
|
19
|
+
_SHOWN = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, eq=False) # its arrays have no single truth value
|
|
23
|
+
class FeatureTable:
|
|
24
|
+
matrix: npt.NDArray[np.float64] # samples × features, Fortran order
|
|
25
|
+
samples: pa.Array # sorted, normalized IDs
|
|
26
|
+
features: pa.Array
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def feature_table(name: str, table: pa.Table, *, pseudocount: float) -> FeatureTable:
|
|
30
|
+
"""Input `name` as a strictly positive dense matrix, once it keeps the contract."""
|
|
31
|
+
_columns(name, table, _COLUMNS)
|
|
32
|
+
sample, feature = (_ids(name, table, column) for column in _COLUMNS[:2])
|
|
33
|
+
value = _numbers(name, table, "value")
|
|
34
|
+
if table.num_rows == 0:
|
|
35
|
+
raise InvalidInput(f"{name}: no rows")
|
|
36
|
+
samples, features = _sorted_unique(sample), _sorted_unique(feature)
|
|
37
|
+
rows = pc.index_in(sample, value_set=samples).to_numpy()
|
|
38
|
+
columns = pc.index_in(feature, value_set=features).to_numpy()
|
|
39
|
+
cells = rows.astype(np.int64) * len(features) + columns
|
|
40
|
+
|
|
41
|
+
def pairs(codes: Iterable[int]) -> Iterator[tuple[object, object]]:
|
|
42
|
+
for code in codes:
|
|
43
|
+
r, c = divmod(int(code), len(features))
|
|
44
|
+
yield samples[r].as_py(), features[c].as_py()
|
|
45
|
+
|
|
46
|
+
if nulls := value.null_count:
|
|
47
|
+
where = np.sort(cells[value.is_null().to_numpy(zero_copy_only=False)])
|
|
48
|
+
raise _offence(
|
|
49
|
+
name, f"value is null in {nulls} of {table.num_rows} rows", pairs(where)
|
|
50
|
+
)
|
|
51
|
+
codes, counts = np.unique(cells, return_counts=True)
|
|
52
|
+
if (repeats := codes[counts > 1]).size:
|
|
53
|
+
raise _offence(
|
|
54
|
+
name,
|
|
55
|
+
f"{repeats.size} of {codes.size} (sample_id, feature_id) pairs repeat",
|
|
56
|
+
pairs(repeats),
|
|
57
|
+
)
|
|
58
|
+
values = value.to_numpy()
|
|
59
|
+
if (negative := np.sort(cells[values < 0])).size:
|
|
60
|
+
raise _offence(
|
|
61
|
+
name,
|
|
62
|
+
f"{negative.size} of {table.num_rows} values are negative",
|
|
63
|
+
pairs(negative),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
matrix = np.zeros((len(samples), len(features)), order="F")
|
|
67
|
+
matrix[rows, columns] = values
|
|
68
|
+
with np.errstate(over="ignore"): # a cell overflowing to inf is reported below
|
|
69
|
+
matrix += pseudocount
|
|
70
|
+
bad = ~(np.isfinite(matrix) & (matrix > 0))
|
|
71
|
+
if count := int(bad.sum()):
|
|
72
|
+
raise _offence(
|
|
73
|
+
name,
|
|
74
|
+
f"{count} of {matrix.size} cells are not positive and finite",
|
|
75
|
+
pairs(_true_cells(bad)),
|
|
76
|
+
)
|
|
77
|
+
return FeatureTable(matrix, samples, features)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, eq=False)
|
|
81
|
+
class DistanceTable:
|
|
82
|
+
condensed: npt.NDArray[np.float64] # scipy's order: (0, 1), (0, 2), …, (1, 2), …
|
|
83
|
+
ids: pa.Array # sorted, normalized
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def distance_matrix(name: str, table: pa.Table) -> DistanceTable:
|
|
87
|
+
"""Input `name` as a condensed distance matrix, once it keeps the contract.
|
|
88
|
+
|
|
89
|
+
Arrays are freed as soon as they're used: memory peaks at a few times the matrix.
|
|
90
|
+
"""
|
|
91
|
+
_columns(name, table, _DISTANCE_COLUMNS)
|
|
92
|
+
a, b = (_ids(name, table, column) for column in _DISTANCE_COLUMNS[:2])
|
|
93
|
+
if a.type != b.type:
|
|
94
|
+
raise InvalidInput(
|
|
95
|
+
f"{name}: id_a and id_b must be the same kind; got {a.type} and {b.type}"
|
|
96
|
+
)
|
|
97
|
+
distance = _numbers(name, table, "distance")
|
|
98
|
+
rows = table.num_rows
|
|
99
|
+
if rows == 0:
|
|
100
|
+
raise InvalidInput(f"{name}: no rows")
|
|
101
|
+
ids = _sorted_unique(pa.chunked_array([*a.chunks, *b.chunks], a.type))
|
|
102
|
+
i, j = (pc.index_in(column, value_set=ids).to_numpy() for column in (a, b))
|
|
103
|
+
if (selves := np.flatnonzero(i == j)).size:
|
|
104
|
+
raise _offence(
|
|
105
|
+
name,
|
|
106
|
+
f"{selves.size} of {rows} rows pair an ID with itself",
|
|
107
|
+
(ids[k].as_py() for k in np.unique(i[selves])),
|
|
108
|
+
)
|
|
109
|
+
n = len(ids)
|
|
110
|
+
low, high = np.minimum(i, j), np.maximum(i, j)
|
|
111
|
+
del i, j
|
|
112
|
+
codes = _codes(n, low, high)
|
|
113
|
+
del low, high
|
|
114
|
+
|
|
115
|
+
def pairs(codes: Iterable[int]) -> Iterator[tuple[object, object]]:
|
|
116
|
+
for code in codes:
|
|
117
|
+
r = bisect.bisect_right(range(n), code, key=lambda r: _start(n, r)) - 1
|
|
118
|
+
yield ids[r].as_py(), ids[int(code) - _start(n, r) + r + 1].as_py()
|
|
119
|
+
|
|
120
|
+
if nulls := distance.null_count:
|
|
121
|
+
where = np.sort(codes[distance.is_null().to_numpy(zero_copy_only=False)])
|
|
122
|
+
raise _offence(
|
|
123
|
+
name, f"distance is null in {nulls} of {rows} rows", pairs(where)
|
|
124
|
+
)
|
|
125
|
+
ordered = np.sort(codes)
|
|
126
|
+
if (repeated := ordered[1:] == ordered[:-1]).any():
|
|
127
|
+
repeats = np.unique(ordered[1:][repeated])
|
|
128
|
+
unique = rows - int(repeated.sum())
|
|
129
|
+
raise _offence(name, f"{repeats.size} of {unique} pairs repeat", pairs(repeats))
|
|
130
|
+
total = n * (n - 1) // 2
|
|
131
|
+
if missing := total - rows: # each row its own pair, so none is beyond `total`
|
|
132
|
+
raise _offence(
|
|
133
|
+
name,
|
|
134
|
+
f"{missing} of {total} pairs are missing",
|
|
135
|
+
pairs(_gaps(ordered, total)),
|
|
136
|
+
)
|
|
137
|
+
del ordered, repeated
|
|
138
|
+
values = distance.to_numpy()
|
|
139
|
+
bad = ~(np.isfinite(values) & (values >= 0))
|
|
140
|
+
if count := int(bad.sum()):
|
|
141
|
+
raise _offence(
|
|
142
|
+
name,
|
|
143
|
+
f"{count} of {rows} distances are negative or not finite",
|
|
144
|
+
pairs(np.sort(codes[bad])),
|
|
145
|
+
)
|
|
146
|
+
condensed = np.empty(total)
|
|
147
|
+
condensed[codes] = values
|
|
148
|
+
return DistanceTable(condensed, ids)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def same_ids(name: str, ids: pa.Array, other: str, expected: pa.Array) -> None:
|
|
152
|
+
"""Input `name` holds exactly input `other`'s IDs, of their kind."""
|
|
153
|
+
if ids.type != expected.type:
|
|
154
|
+
raise InvalidInput(f"{name}: IDs are {ids.type}, {other}'s are {expected.type}")
|
|
155
|
+
if len(extra := ids.filter(pc.invert(pc.is_in(ids, value_set=expected)))):
|
|
156
|
+
raise _offence(
|
|
157
|
+
name,
|
|
158
|
+
f"{len(extra)} of {len(ids)} IDs are not in {other}",
|
|
159
|
+
extra[:_SHOWN].to_pylist(),
|
|
160
|
+
)
|
|
161
|
+
if len(missing := expected.filter(pc.invert(pc.is_in(expected, value_set=ids)))):
|
|
162
|
+
raise _offence(
|
|
163
|
+
name,
|
|
164
|
+
f"{len(missing)} of {len(expected)} {other} IDs are missing",
|
|
165
|
+
missing[:_SHOWN].to_pylist(),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def sample_metadata(name: str, table: pa.Table, samples: pa.Array) -> pd.DataFrame:
|
|
170
|
+
"""Input `name`'s covariates for `samples`, in their order, indexed by ID."""
|
|
171
|
+
if table.column_names.count("sample_id") != 1:
|
|
172
|
+
raise InvalidInput(
|
|
173
|
+
f"{name}: needs exactly one sample_id column; "
|
|
174
|
+
f"got {_names(table.column_names)}"
|
|
175
|
+
)
|
|
176
|
+
covariates = [c for c in table.column_names if c != "sample_id"]
|
|
177
|
+
if repeated := [c for c, n in Counter(covariates).items() if n > 1]:
|
|
178
|
+
raise InvalidInput(f"{name}: column {repeated[0]!r} appears twice")
|
|
179
|
+
values = pa.table({c: _covariate(name, c, table[c]) for c in covariates})
|
|
180
|
+
ids = _ids(name, table, "sample_id")
|
|
181
|
+
if ids.type != samples.type:
|
|
182
|
+
raise InvalidInput(
|
|
183
|
+
f"{name}: sample_id is {ids.type}, the table's is {samples.type}"
|
|
184
|
+
)
|
|
185
|
+
counts = pc.value_counts(ids)
|
|
186
|
+
if len(counts) < len(ids):
|
|
187
|
+
repeats = pc.filter(
|
|
188
|
+
counts.field("values"), pc.greater(counts.field("counts"), 1)
|
|
189
|
+
)
|
|
190
|
+
raise _offence(
|
|
191
|
+
name,
|
|
192
|
+
f"{len(repeats)} of {len(counts)} sample_ids repeat",
|
|
193
|
+
sorted(repeats.to_pylist()),
|
|
194
|
+
)
|
|
195
|
+
positions = pc.index_in(samples, value_set=ids)
|
|
196
|
+
if missing := positions.null_count:
|
|
197
|
+
raise _offence(
|
|
198
|
+
name,
|
|
199
|
+
f"{missing} of {len(samples)} table samples are missing",
|
|
200
|
+
sorted(samples.filter(positions.is_null()).to_pylist()),
|
|
201
|
+
)
|
|
202
|
+
aligned = values.take(positions)
|
|
203
|
+
frame = aligned.to_pandas()
|
|
204
|
+
frame.index = pd.Index(samples.to_pylist())
|
|
205
|
+
for column in covariates:
|
|
206
|
+
_check_defined(name, column, aligned[column], samples)
|
|
207
|
+
if isinstance(frame[column].dtype, pd.CategoricalDtype):
|
|
208
|
+
frame[column] = frame[column].cat.remove_unused_categories()
|
|
209
|
+
return frame
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _ids(name: str, table: pa.Table, column: str) -> pa.ChunkedArray:
|
|
213
|
+
"""`column`'s IDs as int64 or string."""
|
|
214
|
+
ids = table[column]
|
|
215
|
+
if pa.types.is_dictionary(ids.type):
|
|
216
|
+
ids = ids.cast(ids.type.value_type)
|
|
217
|
+
if ids.null_count:
|
|
218
|
+
raise InvalidInput(
|
|
219
|
+
f"{name}: {column} is null in {ids.null_count} of {len(ids)} rows"
|
|
220
|
+
)
|
|
221
|
+
if pa.types.is_integer(ids.type):
|
|
222
|
+
try:
|
|
223
|
+
return ids.cast(pa.int64())
|
|
224
|
+
except pa.ArrowInvalid as e:
|
|
225
|
+
raise InvalidInput(f"{name}: {column} must fit int64") from e
|
|
226
|
+
if _is_string(ids.type):
|
|
227
|
+
return ids.cast(pa.string())
|
|
228
|
+
raise InvalidInput(f"{name}: {column} must be integers or strings, not {ids.type}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_string(kind: pa.DataType) -> bool:
|
|
232
|
+
return bool(
|
|
233
|
+
pa.types.is_string(kind)
|
|
234
|
+
or pa.types.is_large_string(kind)
|
|
235
|
+
or pa.types.is_string_view(kind)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _sorted_unique(ids: pa.ChunkedArray | pa.Array) -> pa.Array:
|
|
240
|
+
unique = pc.unique(ids)
|
|
241
|
+
return unique.take(pc.sort_indices(unique))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _covariate(name: str, column: str, values: pa.ChunkedArray) -> pa.ChunkedArray:
|
|
245
|
+
"""`column`, checked, with strings as `string`."""
|
|
246
|
+
kind = values.type
|
|
247
|
+
if pa.types.is_dictionary(kind) and _is_string(kind.value_type):
|
|
248
|
+
dictionaries = [chunk.dictionary for chunk in values.chunks]
|
|
249
|
+
if any(not d.equals(dictionaries[0]) for d in dictionaries):
|
|
250
|
+
raise InvalidInput(
|
|
251
|
+
f"{name}: column {column!r} has a different dictionary in some chunk"
|
|
252
|
+
)
|
|
253
|
+
if dictionaries and len(pc.unique(dictionaries[0])) < len(dictionaries[0]):
|
|
254
|
+
raise InvalidInput(f"{name}: column {column!r} repeats a dictionary value")
|
|
255
|
+
if dictionaries and dictionaries[0].null_count:
|
|
256
|
+
# Arrow's null checks see only indices, so this would reach pandas.
|
|
257
|
+
raise InvalidInput(
|
|
258
|
+
f"{name}: column {column!r} has a null in its dictionary"
|
|
259
|
+
)
|
|
260
|
+
return values
|
|
261
|
+
if not (
|
|
262
|
+
pa.types.is_boolean(kind)
|
|
263
|
+
or pa.types.is_integer(kind)
|
|
264
|
+
or pa.types.is_floating(kind)
|
|
265
|
+
or _is_string(kind)
|
|
266
|
+
):
|
|
267
|
+
raise InvalidInput(
|
|
268
|
+
f"{name}: column {column!r} is {kind}; covariates are booleans, integers, "
|
|
269
|
+
"floating point, strings, or dictionaries of strings"
|
|
270
|
+
)
|
|
271
|
+
return values.cast(pa.string()) if _is_string(kind) else values
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _check_defined(
|
|
275
|
+
name: str, column: str, values: pa.ChunkedArray, samples: pa.Array
|
|
276
|
+
) -> None:
|
|
277
|
+
undefined = values.is_null()
|
|
278
|
+
problem = "null"
|
|
279
|
+
if pa.types.is_floating(values.type):
|
|
280
|
+
undefined = pc.or_(undefined, pc.invert(pc.is_finite(values)).fill_null(True))
|
|
281
|
+
problem = "null, NaN or infinite"
|
|
282
|
+
if count := pc.sum(undefined).as_py():
|
|
283
|
+
raise _offence(
|
|
284
|
+
name,
|
|
285
|
+
f"column {column!r} is {problem} for {count} of {len(samples)} samples",
|
|
286
|
+
sorted(samples.filter(undefined).to_pylist()),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _columns(name: str, table: pa.Table, columns: list[str]) -> None:
|
|
291
|
+
if sorted(table.column_names) != sorted(columns):
|
|
292
|
+
raise InvalidInput(
|
|
293
|
+
f"{name}: columns must be {', '.join(columns)}; "
|
|
294
|
+
f"got {_names(table.column_names)}"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _numbers(name: str, table: pa.Table, column: str) -> pa.ChunkedArray:
|
|
299
|
+
values = table[column]
|
|
300
|
+
if not (pa.types.is_integer(values.type) or pa.types.is_floating(values.type)):
|
|
301
|
+
raise InvalidInput(
|
|
302
|
+
f"{name}: {column} must be integers or floating point, not {values.type}"
|
|
303
|
+
)
|
|
304
|
+
return values
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _start(n: int, row: int) -> int:
|
|
308
|
+
"""Where `row`'s pairs start in the condensed form of `n` IDs."""
|
|
309
|
+
return row * (2 * n - row - 1) // 2
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _codes(
|
|
313
|
+
n: int, low: npt.NDArray[np.int32], high: npt.NDArray[np.int32]
|
|
314
|
+
) -> npt.NDArray[np.int64]:
|
|
315
|
+
"""Each pair's place in the condensed form: `_start` of `low`, then `high`'s offset.
|
|
316
|
+
|
|
317
|
+
In place, so there is one array the size of the rows at a time.
|
|
318
|
+
"""
|
|
319
|
+
codes = low.astype(np.int64)
|
|
320
|
+
np.subtract(2 * n - 1, codes, out=codes)
|
|
321
|
+
codes *= low
|
|
322
|
+
codes //= 2
|
|
323
|
+
codes += high
|
|
324
|
+
codes -= low
|
|
325
|
+
codes -= 1
|
|
326
|
+
return codes
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _gaps(present: npt.NDArray[np.int64], total: int) -> Iterator[int]:
|
|
330
|
+
"""The codes below `total` that sorted, distinct `present` lacks, in order."""
|
|
331
|
+
yield from range(int(present[0]))
|
|
332
|
+
for k in np.flatnonzero(np.diff(present) > 1):
|
|
333
|
+
yield from range(int(present[k]) + 1, int(present[k + 1]))
|
|
334
|
+
yield from range(int(present[-1]) + 1, total)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _true_cells(mask: npt.NDArray[np.bool_]) -> Iterator[int]:
|
|
338
|
+
"""Codes of `mask`'s true cells, in row order."""
|
|
339
|
+
# A row at a time: np.nonzero would index every true cell at once, 16 bytes each.
|
|
340
|
+
for r, row in enumerate(mask):
|
|
341
|
+
for c in np.flatnonzero(row):
|
|
342
|
+
yield r * mask.shape[1] + int(c)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _names(columns: Iterable[str]) -> str:
|
|
346
|
+
return ", ".join(repr(c) for c in columns)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _offence(name: str, problem: str, examples: Iterable[object]) -> InvalidInput:
|
|
350
|
+
shown = ", ".join(repr(e) for e in itertools.islice(examples, _SHOWN))
|
|
351
|
+
return InvalidInput(f"{name}: {problem}, e.g. {shown}")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""ANCOM-BC differential abundance (docs/capabilities.md#ancombc)."""
|
|
2
|
+
|
|
3
|
+
import keyword
|
|
4
|
+
import unicodedata
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
from patsy import INTERCEPT, ModelDesc, PatsyError
|
|
12
|
+
from skbio.stats.composition import ancombc
|
|
13
|
+
|
|
14
|
+
from skb_arrow.capabilities import _tables
|
|
15
|
+
from skb_arrow.errors import InvalidParam
|
|
16
|
+
|
|
17
|
+
POSTHOC = ("global", "pairwise", "dunnett") # the order their rows come in
|
|
18
|
+
P_ADJUST = ("holm", "bonferroni", "bh", "by")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(tables: Mapping[str, pa.Table], params: Mapping[str, Any]) -> pa.Table:
|
|
22
|
+
# What needs no data is checked before the tables are built.
|
|
23
|
+
terms = _terms(params["formula"])
|
|
24
|
+
grouping = params["grouping"]
|
|
25
|
+
if grouping is None and params["posthoc"]:
|
|
26
|
+
raise InvalidParam("ancombc param 'posthoc' needs 'grouping'")
|
|
27
|
+
counts = _tables.feature_table(
|
|
28
|
+
"table", tables["table"], pseudocount=params["pseudocount"]
|
|
29
|
+
)
|
|
30
|
+
metadata = _tables.sample_metadata("metadata", tables["metadata"], counts.samples)
|
|
31
|
+
for code in _codes(terms):
|
|
32
|
+
if code not in metadata.columns:
|
|
33
|
+
raise InvalidParam(
|
|
34
|
+
f"ancombc param 'formula' names {code!r}, not a metadata column"
|
|
35
|
+
)
|
|
36
|
+
if grouping is not None:
|
|
37
|
+
_check_grouping(grouping, terms, metadata)
|
|
38
|
+
result = ancombc(
|
|
39
|
+
counts.matrix,
|
|
40
|
+
metadata,
|
|
41
|
+
params["formula"],
|
|
42
|
+
grouping,
|
|
43
|
+
max_iter=params["max_iter"],
|
|
44
|
+
tol=params["tol"],
|
|
45
|
+
alpha=params["alpha"],
|
|
46
|
+
p_adjust=params["p_adjust"],
|
|
47
|
+
)
|
|
48
|
+
tests = {
|
|
49
|
+
"global": result.global_test,
|
|
50
|
+
"pairwise": result.pairwise_test,
|
|
51
|
+
"dunnett": lambda: result.dunnett_test(
|
|
52
|
+
bootstraps=params["bootstraps"], seed=params["seed"]
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
frames = [("main", result.result)]
|
|
56
|
+
frames += [(test, tests[test]()) for test in POSTHOC if test in params["posthoc"]]
|
|
57
|
+
schema = _schema(counts.features.type)
|
|
58
|
+
return pa.concat_tables(
|
|
59
|
+
_rows(test, frame, counts.features, schema) for test, frame in frames
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _terms(formula: str) -> list[Any]:
|
|
64
|
+
"""`formula`'s patsy terms, once its factors are all bare names.
|
|
65
|
+
|
|
66
|
+
Nothing is evaluated: patsy would run a factor's code as Python (DESIGN §3.11).
|
|
67
|
+
"""
|
|
68
|
+
what = "ancombc param 'formula'"
|
|
69
|
+
try:
|
|
70
|
+
description = ModelDesc.from_formula(formula)
|
|
71
|
+
except PatsyError as e:
|
|
72
|
+
raise InvalidParam(f"{what} does not parse: {e}") from e
|
|
73
|
+
except (AssertionError, RecursionError) as e: # patsy's tokenizer, its parser
|
|
74
|
+
raise InvalidParam(f"{what} does not parse") from e
|
|
75
|
+
if description.lhs_termlist:
|
|
76
|
+
raise InvalidParam(f"{what} must have no left-hand side")
|
|
77
|
+
terms: list[Any] = description.rhs_termlist
|
|
78
|
+
if not _codes(terms):
|
|
79
|
+
raise InvalidParam(f"{what} names no metadata column")
|
|
80
|
+
for code in _codes(terms):
|
|
81
|
+
if not _bare(code):
|
|
82
|
+
raise InvalidParam(f"{what} names {code!r}, not a bare column name")
|
|
83
|
+
return terms
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _codes(terms: list[Any]) -> list[str]:
|
|
87
|
+
return [factor.code for term in terms for factor in term.factors]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bare(code: str) -> bool:
|
|
91
|
+
"""Whether Python reads `code` as a lookup of exactly that name."""
|
|
92
|
+
return (
|
|
93
|
+
code.isidentifier()
|
|
94
|
+
and not keyword.iskeyword(code)
|
|
95
|
+
and code != "__debug__" # a constant
|
|
96
|
+
and unicodedata.normalize("NFKC", code) == code # as Python normalizes names
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_grouping(grouping: str, terms: list[Any], metadata: pd.DataFrame) -> None:
|
|
101
|
+
what = "ancombc param 'grouping'"
|
|
102
|
+
if grouping not in metadata.columns:
|
|
103
|
+
raise InvalidParam(f"{what} names {grouping!r}, not a metadata column")
|
|
104
|
+
kind = metadata[grouping].dtype
|
|
105
|
+
if pd.api.types.is_numeric_dtype(kind): # booleans too: two groups at most
|
|
106
|
+
raise InvalidParam(f"{what} must be strings or a dictionary")
|
|
107
|
+
if not any([f.code for f in term.factors] == [grouping] for term in terms):
|
|
108
|
+
raise InvalidParam(f"{what} must be a term of its own in 'formula'")
|
|
109
|
+
if INTERCEPT not in terms: # without one, groups are tested against zero
|
|
110
|
+
raise InvalidParam(f"{what} needs an intercept in 'formula'")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _schema(feature_id: pa.DataType) -> pa.Schema:
|
|
114
|
+
return pa.schema(
|
|
115
|
+
[
|
|
116
|
+
pa.field("feature_id", feature_id, nullable=False),
|
|
117
|
+
pa.field("test", pa.string(), nullable=False),
|
|
118
|
+
pa.field("term", pa.string()),
|
|
119
|
+
pa.field("lfc", pa.float64()),
|
|
120
|
+
pa.field("se", pa.float64()),
|
|
121
|
+
pa.field("w", pa.float64(), nullable=False),
|
|
122
|
+
pa.field("pvalue", pa.float64(), nullable=False),
|
|
123
|
+
pa.field("qvalue", pa.float64(), nullable=False),
|
|
124
|
+
pa.field("signif", pa.bool_(), nullable=False),
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _rows(
|
|
130
|
+
test: str, frame: pd.DataFrame, features: pa.Array, schema: pa.Schema
|
|
131
|
+
) -> pa.Table:
|
|
132
|
+
"""`frame`, a scikit-bio result on a bare matrix, as output rows."""
|
|
133
|
+
n = len(frame)
|
|
134
|
+
termed = frame.index.nlevels == 2 # (feature, term); `global` has no term
|
|
135
|
+
|
|
136
|
+
def number(column: str) -> pa.Array:
|
|
137
|
+
# From numpy, so NaN stays NaN; from pandas it would become null.
|
|
138
|
+
return pa.array(frame[column].to_numpy(dtype=np.float64))
|
|
139
|
+
|
|
140
|
+
absent = pa.nulls(n, pa.float64())
|
|
141
|
+
return pa.table(
|
|
142
|
+
{
|
|
143
|
+
# A bare matrix's features are its column positions.
|
|
144
|
+
"feature_id": features.take(frame.index.get_level_values(0).to_numpy()),
|
|
145
|
+
"test": pa.repeat(pa.scalar(test, pa.string()), n),
|
|
146
|
+
"term": (
|
|
147
|
+
pa.array(frame.index.get_level_values(1).tolist(), pa.string())
|
|
148
|
+
if termed
|
|
149
|
+
else pa.nulls(n, pa.string())
|
|
150
|
+
),
|
|
151
|
+
"lfc": number("Log(FC)") if termed else absent,
|
|
152
|
+
"se": number("SE") if termed else absent,
|
|
153
|
+
"w": number("W"),
|
|
154
|
+
"pvalue": number("pvalue"),
|
|
155
|
+
"qvalue": number("qvalue"),
|
|
156
|
+
"signif": pa.array(frame["Signif"].to_numpy(dtype=bool)),
|
|
157
|
+
},
|
|
158
|
+
schema=schema,
|
|
159
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The Mantel test between two distance tables (docs/capabilities.md#mantel)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
from skbio import DistanceMatrix
|
|
8
|
+
from skbio.stats.distance import mantel
|
|
9
|
+
|
|
10
|
+
from skb_arrow.capabilities import _tables
|
|
11
|
+
|
|
12
|
+
METHODS = ("pearson", "spearman", "kendalltau")
|
|
13
|
+
ALTERNATIVES = ("two-sided", "greater", "less")
|
|
14
|
+
_SCHEMA = pa.schema(
|
|
15
|
+
[
|
|
16
|
+
pa.field("statistic", pa.float64(), nullable=False),
|
|
17
|
+
pa.field("pvalue", pa.float64(), nullable=False),
|
|
18
|
+
pa.field("n", pa.int64(), nullable=False),
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(tables: Mapping[str, pa.Table], params: Mapping[str, Any]) -> pa.Table:
|
|
24
|
+
x, y = (_tables.distance_matrix(name, tables[name]) for name in ("x", "y"))
|
|
25
|
+
_tables.same_ids("y", y.ids, "x", x.ids)
|
|
26
|
+
# Both in sorted ID order: scikit-bio's default IDs then match them as they are.
|
|
27
|
+
statistic, pvalue, n = mantel(
|
|
28
|
+
DistanceMatrix(x.condensed, condensed=True),
|
|
29
|
+
DistanceMatrix(y.condensed, condensed=True),
|
|
30
|
+
method=params["method"],
|
|
31
|
+
permutations=params["permutations"],
|
|
32
|
+
alternative=params["alternative"],
|
|
33
|
+
seed=params["seed"],
|
|
34
|
+
engine="numba", # DESIGN §3.9
|
|
35
|
+
)
|
|
36
|
+
return pa.table(
|
|
37
|
+
{"statistic": [statistic], "pvalue": [pvalue], "n": [n]}, schema=_SCHEMA
|
|
38
|
+
)
|
skb_arrow/cli.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main(argv: list[str] | None = None) -> int:
|
|
7
|
+
parser = argparse.ArgumentParser(prog="skb-arrow", color=False)
|
|
8
|
+
mode = parser.add_mutually_exclusive_group()
|
|
9
|
+
mode.add_argument(
|
|
10
|
+
"--version", action="store_true", help="print version, protocol, capabilities"
|
|
11
|
+
)
|
|
12
|
+
mode.add_argument(
|
|
13
|
+
"--doctor", action="store_true", help="report on this install; 1 if unsound"
|
|
14
|
+
)
|
|
15
|
+
mode.add_argument(
|
|
16
|
+
"--segment-dir", type=Path, metavar="DIR", help="serve, with segments in DIR"
|
|
17
|
+
)
|
|
18
|
+
args = parser.parse_args(argv)
|
|
19
|
+
if args.segment_dir is not None:
|
|
20
|
+
from skb_arrow import host
|
|
21
|
+
|
|
22
|
+
return host.serve(args.segment_dir, *host.reserve())
|
|
23
|
+
if not (args.version or args.doctor):
|
|
24
|
+
parser.print_usage(sys.stderr)
|
|
25
|
+
return 2
|
|
26
|
+
# Here, not at module level: nothing heavy may load before host.reserve().
|
|
27
|
+
from skb_arrow import doctor
|
|
28
|
+
|
|
29
|
+
if args.doctor:
|
|
30
|
+
return doctor.report()
|
|
31
|
+
print("\n".join(doctor.versions()))
|
|
32
|
+
return 0
|