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 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,8 @@
1
+ from collections.abc import Mapping
2
+
3
+ import pyarrow as pa
4
+
5
+
6
+ def run(tables: Mapping[str, pa.Table], params: Mapping[str, object]) -> pa.Table:
7
+ """Its input, unchanged: exercises the machinery."""
8
+ return tables["table"]
@@ -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