ldpart 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.
ldpart/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ ldpart: certified multicollinearity partitioning of LD matrices.
3
+
4
+ Group SNPs into contiguous blocks of correlated SNPs from a square LD
5
+ matrix (r or r2 with 1 on the diagonal, SNPs ordered as the rows and
6
+ columns), with six greedy criteria built on classical
7
+ multicollinearity diagnostics: the determinant (fast Schur updates
8
+ and the LAPACK slogdet reference), the condition number, the maximal
9
+ variance inflation factor, the sum of reciprocal eigenvalues, and the
10
+ marginal pairwise r2 screen. Every saved block is certified: it
11
+ satisfies its criterion, and the monotone criteria additionally
12
+ certify maximality. See partitioner for the semantics.
13
+ """
14
+
15
+ from .partitioner import (
16
+ partition_by_determinant,
17
+ partition_by_determinant_slogdet,
18
+ partition_by_condition,
19
+ partition_by_vif,
20
+ partition_by_reciprocal_eigenvalues,
21
+ partition_by_pairwise,
22
+ labels_to_blocks,
23
+ )
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "partition_by_determinant",
29
+ "partition_by_determinant_slogdet",
30
+ "partition_by_condition",
31
+ "partition_by_vif",
32
+ "partition_by_reciprocal_eigenvalues",
33
+ "partition_by_pairwise",
34
+ "labels_to_blocks",
35
+ "__version__",
36
+ ]
ldpart/cli.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ Command line interface of ldpart: partition a square LD matrix into
3
+ blocks of correlated SNPs by a multicollinearity criterion. The input
4
+ file holds the matrix (1 on the diagonal, SNPs ordered as the rows and
5
+ columns) in HDF5; the output holds two columns, the zero-based SNP
6
+ index and the block label of every SNP, the format consumed by the
7
+ reproducibility layer of the ld-matrix-partition-01 project.
8
+
9
+ Installed as the console script `ldpart`:
10
+
11
+ ldpart -i LD.h5 -o labels.txt -m fast -d 0.001
12
+ ldpart -i LD.h5 -o labels.txt -m condition -c 1000
13
+ ldpart -i LD.h5 -o labels.txt -m vif -v 10
14
+ ldpart -i LD.h5 -o labels.txt -m reciprocal -r 5
15
+ ldpart -i LD.h5 -o labels.txt -m pairwise -p 0.8
16
+ """
17
+
18
+ import argparse
19
+ import time
20
+
21
+ import numpy as np
22
+ import h5py
23
+
24
+ from . import __version__
25
+ from .helpers import (attach_matrix, check_input_files,
26
+ check_output_dir, show_time_elapsed)
27
+ from .partitioner import (
28
+ partition_by_determinant,
29
+ partition_by_determinant_slogdet,
30
+ partition_by_condition,
31
+ partition_by_vif,
32
+ partition_by_reciprocal_eigenvalues,
33
+ partition_by_pairwise,
34
+ )
35
+
36
+ # Method -> (partition function, criterion argument, printed label)
37
+ METHODS = {
38
+ "fast": (partition_by_determinant, "min_det", "Minimal determinant"),
39
+ "slogdet": (partition_by_determinant_slogdet, "min_det",
40
+ "Minimal determinant"),
41
+ "condition": (partition_by_condition, "max_cond",
42
+ "Maximal condition number"),
43
+ "vif": (partition_by_vif, "max_vif", "Maximal VIF"),
44
+ "reciprocal": (partition_by_reciprocal_eigenvalues, "max_ratio",
45
+ "Maximal sum of reciprocal eigenvalues per block "
46
+ "size"),
47
+ "pairwise": (partition_by_pairwise, "min_r2",
48
+ "Minimal pairwise r2"),
49
+ }
50
+
51
+
52
+ def parser_args(argv=None) -> argparse.Namespace:
53
+ parser = argparse.ArgumentParser(
54
+ prog="ldpart",
55
+ description="Partition a square LD matrix into blocks of "
56
+ "correlated SNPs by a multicollinearity criterion")
57
+ parser.add_argument("-i", "--input", required=True,
58
+ help="/path/to/input.h5 with the matrix in "
59
+ "HDF5 format")
60
+ parser.add_argument("-o", "--output", required=True,
61
+ help="/path/to/output.txt for the block labels")
62
+ parser.add_argument("-d", "--min_det", type=float, default=0.001,
63
+ help="minimal determinant of a block submatrix, "
64
+ "methods fast and slogdet (default 0.001)")
65
+ parser.add_argument("-c", "--max_cond", type=float, default=1000.0,
66
+ help="maximal condition number of a block "
67
+ "submatrix, method condition "
68
+ "(default 1000)")
69
+ parser.add_argument("-v", "--max_vif", type=float, default=10.0,
70
+ help="maximal variance inflation factor of a "
71
+ "block submatrix, method vif (default 10)")
72
+ parser.add_argument("-r", "--max_ratio", type=float, default=5.0,
73
+ help="maximal sum of reciprocal eigenvalues "
74
+ "per block size, method reciprocal "
75
+ "(default 5)")
76
+ parser.add_argument("-p", "--min_r2", type=float, default=0.8,
77
+ help="minimal pairwise r2 between the "
78
+ "candidate and a block member, method "
79
+ "pairwise (default 0.8)")
80
+ parser.add_argument("-m", "--method", choices=tuple(METHODS),
81
+ default="fast",
82
+ help="partition function: fast, slogdet, "
83
+ "condition, vif, reciprocal or pairwise "
84
+ "(default fast)")
85
+ parser.add_argument("--version", action="version",
86
+ version=f"ldpart {__version__}")
87
+ return parser.parse_args(argv)
88
+
89
+
90
+ def main(argv=None) -> None:
91
+ args = parser_args(argv)
92
+ ts = time.time()
93
+
94
+ check_input_files([args.input])
95
+ check_output_dir(args.output)
96
+
97
+ print("Input:", args.input)
98
+ print("Output:", args.output)
99
+ print("Method:", args.method)
100
+
101
+ fun, arg_name, label = METHODS[args.method]
102
+ params = {arg_name: getattr(args, arg_name)}
103
+ print(label + ":", params[arg_name])
104
+
105
+ with h5py.File(args.input, "r") as f:
106
+ matrix = attach_matrix(f)
107
+ labels, nblocks = fun(matrix, **params)
108
+
109
+ np.savetxt(args.output, labels, delimiter=" ", fmt="%d")
110
+ print("Number of blocks:", nblocks)
111
+ show_time_elapsed(ts)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
ldpart/helpers.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ Assisting functions of the ldpart command line interface: the LD
3
+ matrix is read straight from its HDF5 dataset (no copy into memory),
4
+ and the small file and folder checks live here. Trimmed from the
5
+ helpers of the ld-matrix-partition-01 project, pandas free.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import time
11
+ from typing import Union
12
+
13
+ import h5py
14
+
15
+
16
+ def attach_matrix(file_obj: h5py.File) -> Union[h5py.Dataset,
17
+ h5py.Group]:
18
+ """
19
+ The square matrix of the first object of an open HDF5 file: a
20
+ plain dataset, or a group of chunked pandas arrays
21
+ (block0_values).
22
+ """
23
+ key = list(file_obj.keys())[0]
24
+ obj = file_obj[key]
25
+ if isinstance(obj, h5py.Dataset):
26
+ ds_obj = obj
27
+ elif isinstance(obj, h5py.Group):
28
+ ds_obj = obj["block0_values"]
29
+ else:
30
+ print("Unknown type of dataset")
31
+ sys.exit(1)
32
+
33
+ n, m = ds_obj.shape[0], ds_obj.shape[1]
34
+ if n != m:
35
+ print("Dataset is not a square matrix")
36
+ sys.exit(1)
37
+ print(f"Dataset shape: {n} x {m}")
38
+ return ds_obj
39
+
40
+
41
+ def check_output_dir(output: str) -> None:
42
+ # Create the folder holding an output path when missing
43
+ dir_name = os.path.dirname(output)
44
+ if dir_name:
45
+ os.makedirs(dir_name, exist_ok=True)
46
+
47
+
48
+ def check_input_files(files: list) -> None:
49
+ for file in files:
50
+ if file and not os.path.isfile(file):
51
+ print(f"{file} doesn't exist")
52
+ sys.exit(1)
53
+
54
+
55
+ def show_time_elapsed(ts: float) -> None:
56
+ dur = time.strftime("%H:%M:%S", time.gmtime(time.time() - ts))
57
+ print("Time spent:", dur)
ldpart/partitioner.py ADDED
@@ -0,0 +1,509 @@
1
+ """
2
+ Library of functions to group SNPs into blocks of correlated SNPs.
3
+
4
+ The input is a square symmetric LD matrix (r or r2 with 1 on the diagonal,
5
+ SNPs ordered as the rows/columns), either a numpy array or an h5py dataset.
6
+ Every grouping function returns (labels, nblocks), where labels is a per-SNP
7
+ list [index, block_label] with blocks numbered from 1. Each SNP gets exactly
8
+ one label, and blocks are contiguous ranges of indices. The two-column labels
9
+ can be saved and passed to 2hlist-02.py (det-01 project) to build *.hlist
10
+ files for haplotype inference and testing in PLINK.
11
+
12
+ Functions
13
+ ---------
14
+ partition_by_determinant determinant criterion, incremental Schur
15
+ complement updates (fast)
16
+ partition_by_determinant_slogdet determinant criterion, full LAPACK slogdet
17
+ of the growing window at each step
18
+ (reference implementation)
19
+ partition_by_condition condition number criterion (squared
20
+ Belsley condition number), full LAPACK
21
+ eigvalsh of the growing window
22
+ partition_by_vif maximal variance inflation factor (VIF)
23
+ criterion, per-window eigendecomposition
24
+ partition_by_reciprocal_eigenvalues sum of reciprocal eigenvalues criterion
25
+ (= sum of VIF = trace of the inverse)
26
+ partition_by_pairwise pairwise r2 with the block (classic
27
+ |r| > 0.8 screen), incremental row maximum
28
+ labels_to_blocks convert labels into a list of blocks
29
+
30
+ Corrected semantics (see det-01/audit-partition-ld-03.md)
31
+ ---------------------------------------------------------
32
+ Unlike det-01/partition-ld-03.py, the candidate SNP is always part of the
33
+ tested submatrix: the block starting at i1 is extended by SNP j only if
34
+ det(matrix[i1:j+1, i1:j+1]) > min_det with a positive sign. The first
35
+ candidate violating the condition seeds the next block, so every saved
36
+ block is certified: the determinant of its own submatrix exceeds min_det.
37
+ NaN values are treated as a failure to extend, never as an automatic pass.
38
+
39
+ All functions share the same greedy mechanism (grow the block, the first
40
+ failing candidate seeds the next one, NaN never extends a block) and the
41
+ same certified-block semantics; they differ in the tested criterion. Two
42
+ cut directions coexist: the determinant, condition number, VIF and
43
+ reciprocal eigenvalue criteria certify that a block is not TOO jointly
44
+ redundant (a block ends before becoming ill-conditioned), while the
45
+ pairwise criterion groups SNPs that ARE in LD (an uncorrelated SNP starts
46
+ a new block).
47
+
48
+ Command line wrapper: run-partition-01.py in this folder.
49
+
50
+ Part of the ld-matrix-partition-01 project.
51
+ Created: September 8, 2026
52
+ """
53
+
54
+ import numpy as np
55
+
56
+
57
+ def _extend_inverse(inv: np.ndarray, u: np.ndarray, s: float) -> np.ndarray:
58
+ """
59
+ Inverse of the extended matrix [[M, c], [c.T, d]] given the inverse of M,
60
+ u = M^-1 c, and the Schur complement s = d - c.T u (Sherman-Morrison).
61
+ """
62
+ k = u.shape[0]
63
+ w = u / s
64
+ new_inv = np.empty((k + 1, k + 1), dtype=np.float64)
65
+ new_inv[:k, :k] = inv + np.outer(w, u)
66
+ new_inv[:k, k] = -w
67
+ new_inv[k, :k] = -w
68
+ new_inv[k, k] = 1.0 / s
69
+ return new_inv
70
+
71
+
72
+ def _check_matrix(matrix) -> int:
73
+ # Get the size of a square 2D matrix
74
+ shape = matrix.shape
75
+ if len(shape) != 2 or shape[0] != shape[1]:
76
+ raise ValueError("Expected a square matrix")
77
+ return shape[0]
78
+
79
+
80
+ def _seed(matrix, i: int):
81
+ # State of a block that consists of the single SNP i:
82
+ # sign and log|det| of its 1x1 submatrix, and its inverse
83
+ d = float(matrix[i, i])
84
+ if np.isnan(d) or d <= 0:
85
+ # Uncertifiable seed: the block stays a singleton
86
+ return 0, 0.0, np.ones((1, 1))
87
+ return 1, np.log(d), np.array([[1.0 / d]])
88
+
89
+
90
+ def partition_by_determinant(matrix, min_det: float = 0.001):
91
+ """
92
+ Group SNPs into blocks by the determinant of the LD submatrix (fast).
93
+
94
+ The block starting at SNP i1 is extended by the candidate SNP j while
95
+ det(matrix[i1:j+1, i1:j+1]) > min_det holds with a positive sign. The
96
+ first candidate violating the condition seeds the next block. Since the
97
+ determinant of a correlation matrix can only decrease when a SNP is
98
+ added (each step multiplies it by 1 - R^2 <= 1), the greedy growth finds
99
+ the maximal block for the criterion.
100
+
101
+ The determinant is carried along incrementally with the Schur complement
102
+ det(M') = det(M) * (d - c.T M^-1 c), M' = [[M, c], [c.T, d]],
103
+ which needs one row of the matrix and O(k^2) work per step, instead of
104
+ a full O(k^3) factorization of the growing window (O(k^4) per block in
105
+ total). The explicit inverse is updated with the Sherman-Morrison
106
+ formula. On ill-conditioned blocks the accumulated value can differ
107
+ from the LAPACK determinant in the last bits; use
108
+ partition_by_determinant_slogdet() for the reference behaviour.
109
+
110
+ A NaN anywhere in the candidate's row (or a NaN, zero, or negative
111
+ Schur complement) fails the extension: the candidate starts a new
112
+ block. NaN never silently extends a block.
113
+
114
+ Parameters
115
+ ----------
116
+ matrix : h5py.Dataset or numpy.ndarray
117
+ Square symmetric LD matrix (r or r2) with 1 on the diagonal.
118
+ min_det : float
119
+ Minimal determinant of a block submatrix, within (0, 1).
120
+
121
+ Returns
122
+ -------
123
+ list
124
+ Labels [index, block] of the matrix elements
125
+ int
126
+ Number of blocks
127
+ """
128
+ n = _check_matrix(matrix)
129
+ if not 0.0 < min_det < 1.0:
130
+ raise ValueError(f"min_det must be within (0, 1), got {min_det}")
131
+ if n == 0:
132
+ return [], 0
133
+
134
+ log_min = np.log(min_det)
135
+
136
+ # Initiate the first block
137
+ b = 1
138
+ labels = [[0, b]]
139
+ i1 = 0
140
+ sign, logdet, inv = _seed(matrix, 0)
141
+
142
+ # The symmetry of the matrix is used to read the candidate's row
143
+ # (contiguous in memory) instead of its column
144
+ for j in range(1, n):
145
+ extend = False
146
+ if sign > 0:
147
+ row = np.asarray(matrix[j, i1:j + 1], dtype=np.float64)
148
+ if not np.isnan(row).any():
149
+ c, d = row[:-1], row[-1]
150
+ u = inv.dot(c)
151
+ s = d - c.dot(u)
152
+ if not np.isnan(s) and s > 0:
153
+ new_logdet = logdet + np.log(s)
154
+ if new_logdet > log_min:
155
+ extend = True
156
+ if extend:
157
+ # The candidate joins the current block
158
+ labels.append([j, b])
159
+ logdet = new_logdet
160
+ inv = _extend_inverse(inv, u, s)
161
+ else:
162
+ # The candidate fails: it seeds the next block
163
+ b += 1
164
+ labels.append([j, b])
165
+ i1 = j
166
+ sign, logdet, inv = _seed(matrix, j)
167
+
168
+ return labels, b
169
+
170
+
171
+ def partition_by_determinant_slogdet(matrix, min_det: float = 0.001):
172
+ """
173
+ Group SNPs into blocks by the determinant of the LD submatrix
174
+ (reference implementation).
175
+
176
+ Same criterion and semantics as partition_by_determinant(), but the
177
+ determinant of the growing window matrix[i1:j+1, i1:j+1] is recomputed
178
+ with np.linalg.slogdet at every step, which costs O(k^4) flops per
179
+ block. Kept to define the semantics and to verify the fast version.
180
+ Note that LAPACK can return a finite determinant for a window that
181
+ contains a NaN (the entry may stay off the pivoting path), so on a
182
+ matrix with NaNs the fast version, which checks the row explicitly,
183
+ is the authoritative one.
184
+ """
185
+ n = _check_matrix(matrix)
186
+ if not 0.0 < min_det < 1.0:
187
+ raise ValueError(f"min_det must be within (0, 1), got {min_det}")
188
+ if n == 0:
189
+ return [], 0
190
+
191
+ log_min = np.log(min_det)
192
+
193
+ # Initiate the first block
194
+ b = 1
195
+ labels = [[0, b]]
196
+ i1 = 0
197
+
198
+ for j in range(1, n):
199
+ submatrix = np.asarray(matrix[i1:j + 1, i1:j + 1], dtype=np.float64)
200
+ sign, logdet = np.linalg.slogdet(submatrix)
201
+ if (not np.isfinite(logdet)) or sign <= 0 or logdet <= log_min:
202
+ # The candidate fails: it seeds the next block
203
+ b += 1
204
+ labels.append([j, b])
205
+ i1 = j
206
+ else:
207
+ labels.append([j, b])
208
+
209
+ return labels, b
210
+
211
+
212
+ def partition_by_condition(matrix, max_cond: float = 1000.0):
213
+ """
214
+ Group SNPs into blocks by the condition number of the LD submatrix.
215
+
216
+ The block starting at SNP i1 is extended by the candidate SNP j while
217
+ cond(matrix[i1:j+1, i1:j+1]) <= max_cond, where cond is the spectral
218
+ condition number lambda_max/|lambda_min| from np.linalg.eigvalsh. The
219
+ first candidate violating the condition seeds the next block, so every
220
+ saved block is certified: the condition number of its submatrix, and of
221
+ every prefix of it, does not exceed max_cond.
222
+
223
+ By the Cauchy interlacing theorem, appending a row and a column to a
224
+ symmetric matrix can only increase lambda_max and decrease lambda_min,
225
+ so the condition number of the growing window cannot recover after a
226
+ failure: the greedy growth finds the maximal block for the criterion.
227
+ A window that stops being positive definite (lambda_min <= 0) has an
228
+ infinite condition number and always fails, which turns numerical
229
+ singularity into an explicit cut.
230
+
231
+ Unlike the determinant, the condition number never underflows: it stays
232
+ meaningful far beyond the sizes where a determinant leaves the float64
233
+ range. Note the convention: the condition indices of Belsley et al. are
234
+ CI_q = sqrt(lambda_max/lambda_q) with CN = max_q CI_q = sqrt(kappa),
235
+ so this function's criterion kappa <= max_cond with the default 1000
236
+ corresponds to the classical CN = sqrt(1000) ~ 31.6, just above the
237
+ severe-multicollinearity level of 30.
238
+
239
+ A NaN anywhere in the candidate window fails the extension, never an
240
+ automatic pass. The eigenvalues are recomputed from the window with
241
+ LAPACK at every step (O(k^4) flops per block), mirroring
242
+ partition_by_determinant_slogdet().
243
+
244
+ Parameters
245
+ ----------
246
+ matrix : h5py.Dataset or numpy.ndarray
247
+ Square symmetric LD matrix (r or r2) with 1 on the diagonal.
248
+ max_cond : float
249
+ Maximal condition number of a block submatrix, >= 1.
250
+
251
+ Returns
252
+ -------
253
+ list
254
+ Labels [index, block] of the matrix elements
255
+ int
256
+ Number of blocks
257
+ """
258
+ n = _check_matrix(matrix)
259
+ if max_cond < 1.0:
260
+ raise ValueError(f"max_cond must be >= 1, got {max_cond}")
261
+ if n == 0:
262
+ return [], 0
263
+
264
+ # Initiate the first block
265
+ b = 1
266
+ labels = [[0, b]]
267
+ i1 = 0
268
+
269
+ for j in range(1, n):
270
+ submatrix = np.asarray(matrix[i1:j + 1, i1:j + 1], dtype=np.float64)
271
+ if np.isnan(submatrix).any():
272
+ extend = False
273
+ else:
274
+ eig = np.linalg.eigvalsh(submatrix)
275
+ lmin = abs(eig[0])
276
+ cond = eig[-1] / lmin if lmin > 0 else np.inf
277
+ extend = cond <= max_cond
278
+ if extend:
279
+ # The candidate joins the current block
280
+ labels.append([j, b])
281
+ else:
282
+ # The candidate fails: it seeds the next block
283
+ b += 1
284
+ labels.append([j, b])
285
+ i1 = j
286
+
287
+ return labels, b
288
+
289
+
290
+ def partition_by_vif(matrix, max_vif: float = 10.0):
291
+ """
292
+ Group SNPs into blocks by the maximal variance inflation factor (VIF)
293
+ of the block submatrix.
294
+
295
+ VIF_i = (R^-1)_ii = 1 / (1 - R2_i), where R2_i is the multiple
296
+ correlation of SNP i with the other SNPs of the block. The block
297
+ starting at SNP i1 is extended by the candidate SNP j while
298
+ max_i VIF_i <= max_vif over matrix[i1:j+1, i1:j+1]; the first
299
+ candidate violating the condition seeds the next block, so every saved
300
+ block is certified: the maximal VIF of its own submatrix (and of every
301
+ prefix of it) does not exceed max_vif. The equivalent tolerance
302
+ criterion 1/VIF >= 1/max_vif is the same test with an inverted
303
+ threshold. Classical reference levels: VIF 5 moderate, 10 severe
304
+ multicollinearity (tolerance 0.2 / 0.1).
305
+
306
+ Monotonicity: for a positive-definite extension M' = [[M, c], [c.T, d]]
307
+ with u = M^-1 c and Schur complement s = d - c.T u > 0, the updated
308
+ inverse is M'^-1 = [[M^-1 + uu.T/s, -u/s], [-u.T/s, 1/s]], so every
309
+ diagonal entry can only grow ((M'^-1)_ii = (M^-1)_ii + u_i^2/s for the
310
+ old indices, 1/s for the new one): the maximal VIF cannot recover
311
+ after a failure, and the greedy growth finds the maximal block.
312
+
313
+ The VIFs are read from the eigendecomposition R = Q diag(lambda) Q.T:
314
+ (R^-1)_ii = sum_q Q[i,q]^2 / lambda_q, computed fresh for every window
315
+ (O(k^4) flops per block), which also provides the positive-definite
316
+ check: a window with lambda_min <= 0 always fails. A NaN anywhere in
317
+ the window fails the extension, never an automatic pass.
318
+
319
+ Parameters
320
+ ----------
321
+ matrix : h5py.Dataset or numpy.ndarray
322
+ Square symmetric LD matrix (r or r2) with 1 on the diagonal.
323
+ max_vif : float
324
+ Maximal variance inflation factor of a block submatrix, >= 1.
325
+
326
+ Returns
327
+ -------
328
+ list
329
+ Labels [index, block] of the matrix elements
330
+ int
331
+ Number of blocks
332
+ """
333
+ n = _check_matrix(matrix)
334
+ if max_vif < 1.0:
335
+ raise ValueError(f"max_vif must be >= 1, got {max_vif}")
336
+ if n == 0:
337
+ return [], 0
338
+
339
+ # Initiate the first block
340
+ b = 1
341
+ labels = [[0, b]]
342
+ i1 = 0
343
+
344
+ for j in range(1, n):
345
+ submatrix = np.asarray(matrix[i1:j + 1, i1:j + 1], dtype=np.float64)
346
+ if np.isnan(submatrix).any():
347
+ extend = False
348
+ else:
349
+ lam, q = np.linalg.eigh(submatrix)
350
+ if lam[0] > 0:
351
+ vif = (q * q / lam).sum(axis=1).max()
352
+ extend = vif <= max_vif
353
+ else:
354
+ extend = False
355
+ if extend:
356
+ # The candidate joins the current block
357
+ labels.append([j, b])
358
+ else:
359
+ # The candidate fails: it seeds the next block
360
+ b += 1
361
+ labels.append([j, b])
362
+ i1 = j
363
+
364
+ return labels, b
365
+
366
+
367
+ def partition_by_reciprocal_eigenvalues(matrix, max_ratio: float = 5.0):
368
+ """
369
+ Group SNPs into blocks by the sum of reciprocal eigenvalues of the
370
+ block submatrix (Kendall 1957; Silvey 1969).
371
+
372
+ The block starting at SNP i1 is extended by the candidate SNP j while
373
+ sum_q 1/lambda_q <= max_ratio * k over the k x k window
374
+ matrix[i1:j+1, i1:j+1]: the classical rule of Chatterjee & Price
375
+ (1977) and Dillon & Goldstein (1984) flags collinearity when the sum
376
+ of reciprocal eigenvalues is about five times the number of variables.
377
+ Since trace(R^-1) = sum_q 1/lambda_q = sum_i VIF_i, this is the
378
+ aggregate version of the VIF criterion, and every saved block is
379
+ certified: the sum does not exceed max_ratio * size at its final size
380
+ nor at any prefix.
381
+
382
+ Unlike the determinant, condition number and VIF criteria, the ratio
383
+ to the window size can recover after a failure (an almost uncorrelated
384
+ candidate raises k by 1 but the sum by barely more), so the greedy cut
385
+ is deterministic but the blocks are not certified maximal. A window
386
+ that stops being positive definite (lambda_min <= 0) always fails, a
387
+ NaN anywhere in the window fails the extension.
388
+
389
+ The eigenvalues are recomputed from the window with LAPACK at every
390
+ step (O(k^4) flops per block).
391
+
392
+ Parameters
393
+ ----------
394
+ matrix : h5py.Dataset or numpy.ndarray
395
+ Square symmetric LD matrix (r or r2) with 1 on the diagonal.
396
+ max_ratio : float
397
+ Maximal sum of reciprocal eigenvalues per window size, >= 1.
398
+
399
+ Returns
400
+ -------
401
+ list
402
+ Labels [index, block] of the matrix elements
403
+ int
404
+ Number of blocks
405
+ """
406
+ n = _check_matrix(matrix)
407
+ if max_ratio < 1.0:
408
+ raise ValueError(f"max_ratio must be >= 1, got {max_ratio}")
409
+ if n == 0:
410
+ return [], 0
411
+
412
+ # Initiate the first block
413
+ b = 1
414
+ labels = [[0, b]]
415
+ i1 = 0
416
+
417
+ for j in range(1, n):
418
+ submatrix = np.asarray(matrix[i1:j + 1, i1:j + 1], dtype=np.float64)
419
+ if np.isnan(submatrix).any():
420
+ extend = False
421
+ else:
422
+ lam = np.linalg.eigvalsh(submatrix)
423
+ if lam[0] > 0:
424
+ extend = (1.0 / lam).sum() <= max_ratio * lam.size
425
+ else:
426
+ extend = False
427
+ if extend:
428
+ # The candidate joins the current block
429
+ labels.append([j, b])
430
+ else:
431
+ # The candidate fails: it seeds the next block
432
+ b += 1
433
+ labels.append([j, b])
434
+ i1 = j
435
+
436
+ return labels, b
437
+
438
+
439
+ def partition_by_pairwise(matrix, min_r2: float = 0.8):
440
+ """
441
+ Group SNPs into blocks by pairwise LD with the block: the classic
442
+ pairwise correlation screen (absolute r above ~0.8, e.g. Gujarati &
443
+ Porter), applied to an r2 matrix.
444
+
445
+ The block starting at SNP i1 is extended by the candidate SNP j while
446
+ max over the block members of r2(candidate, member) >= min_r2. This
447
+ inverts the cut direction of the determinant, condition number, VIF
448
+ and reciprocal eigenvalue criteria: a SNP in LD with the block JOINS
449
+ it, and an uncorrelated SNP starts a new block. Every saved block is
450
+ certified in the pairwise sense: each member after the first has
451
+ r2 >= min_r2 with an earlier member of the same block. Only pairwise
452
+ (marginal) LD is tested, not the joint redundancy of the block.
453
+
454
+ The candidate needs one row segment and O(k) work per step, so this is
455
+ the cheapest criterion (comparable to PLINK-style LD pruning). A NaN
456
+ in the candidate's row segment fails the extension.
457
+
458
+ Parameters
459
+ ----------
460
+ matrix : h5py.Dataset or numpy.ndarray
461
+ Square symmetric LD matrix (r2, with 1 on the diagonal); for an r
462
+ matrix pass min_r2 as a threshold on r2 = value^2.
463
+ min_r2 : float
464
+ Minimal pairwise r2 between the candidate and a block member,
465
+ within (0, 1].
466
+
467
+ Returns
468
+ -------
469
+ list
470
+ Labels [index, block] of the matrix elements
471
+ int
472
+ Number of blocks
473
+ """
474
+ n = _check_matrix(matrix)
475
+ if not 0.0 < min_r2 <= 1.0:
476
+ raise ValueError(f"min_r2 must be within (0, 1], got {min_r2}")
477
+ if n == 0:
478
+ return [], 0
479
+
480
+ # Initiate the first block
481
+ b = 1
482
+ labels = [[0, b]]
483
+ i1 = 0
484
+
485
+ for j in range(1, n):
486
+ # The symmetry of the matrix is used to read the candidate's row
487
+ # (contiguous in memory) instead of its column
488
+ segment = np.asarray(matrix[j, i1:j], dtype=np.float64)
489
+ if segment.size and not np.isnan(segment).any() and segment.max() >= min_r2:
490
+ # The candidate joins the current block
491
+ labels.append([j, b])
492
+ else:
493
+ # The candidate fails: it seeds the next block
494
+ b += 1
495
+ labels.append([j, b])
496
+ i1 = j
497
+
498
+ return labels, b
499
+
500
+
501
+ def labels_to_blocks(labels: list) -> list:
502
+ """
503
+ Convert labels [index, block] into a list of (block, indices) tuples
504
+ ordered by the block number.
505
+ """
506
+ blocks = {}
507
+ for i, b in labels:
508
+ blocks.setdefault(b, []).append(i)
509
+ return [(b, idx) for b, idx in sorted(blocks.items())]
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: ldpart
3
+ Version: 0.1.0
4
+ Summary: Certified multicollinearity partitioning of LD matrices into blocks of correlated SNPs
5
+ Author-email: Gennady Khvorykh <info@inzilico.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/gkhvorykh/ldpart
8
+ Project-URL: Documentation, https://github.com/gkhvorykh/ldpart#readme
9
+ Keywords: linkage disequilibrium,LD blocks,multicollinearity,variance inflation factor,population genetics,genetics
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.20
23
+ Requires-Dist: h5py>=3.0
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest>=7; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # Linkage Disequilibrium Partition (ldpart)
29
+
30
+ Certified multicollinearity partitioning of linkage disequilibrium (LD) matrices: group SNPs
31
+ into contiguous blocks of correlated SNPs with greedy criteria built
32
+ on classical multicollinearity diagnostics.
33
+
34
+ Every saved block is **certified** — its submatrix of the LD matrix
35
+ provably satisfies the criterion — and the monotone criteria
36
+ (determinant, condition number, maximal VIF) additionally certify that
37
+ every block is **maximal**: no neighbouring SNP could join it without
38
+ violating the criterion. NaN values never extend a block.
39
+
40
+ ## Criteria
41
+
42
+ | function | method | criterion | certified | maximal | cost per candidate |
43
+ |---|---|---|---|---|---|
44
+ | `partition_by_determinant` | `fast` | det(block) > θ (Schur updates) | yes | yes (det is monotone under growth) | O(k²) |
45
+ | `partition_by_determinant_slogdet` | `slogdet` | det(block) > θ (LAPACK reference) | yes | yes | O(k³) |
46
+ | `partition_by_condition` | `condition` | κ(block) = λmax/\|λmin\| ≤ θ | yes | yes (Cauchy interlacing) | O(k³) |
47
+ | `partition_by_vif` | `vif` | max VIF = max diag(R⁻¹) ≤ θ | yes | yes (nested regressions) | O(k³) |
48
+ | `partition_by_reciprocal_eigenvalues` | `reciprocal` | Σ 1/λq ≤ θ·k | yes | no (the ratio can recover) | O(k³) |
49
+ | `partition_by_pairwise` | `pairwise` | max r2(candidate, member) ≥ θ | yes (pairwise sense) | — marginal screen | O(k) |
50
+
51
+ Classical operating points: det θ = 0.001, κ = 1000 (squared Belsley
52
+ CN ≈ 31.6), VIF 5/10, Σ1/λ ≈ 5·k (Chatterjee & Price), pairwise
53
+ r² = 0.8.
54
+
55
+ The determinant, condition, VIF and reciprocal criteria certify that
56
+ a block is **not too jointly redundant** (joint criteria); the
57
+ pairwise criterion groups SNPs that **are** in LD (marginal screen).
58
+ Their cuts land in different places — the joint criteria cut inside
59
+ strong LD where redundancy accumulates, the pairwise criterion cuts
60
+ where LD has decayed.
61
+
62
+ ## Install
63
+
64
+ ```bash
65
+ pip install ldpart
66
+ ```
67
+
68
+ ## Python API
69
+
70
+ The input is a square symmetric LD matrix (r or r2, 1 on the
71
+ diagonal, SNPs ordered as the rows and columns) — a numpy array or an
72
+ open h5py dataset (the matrix is streamed row by row, never copied).
73
+
74
+ ```python
75
+ import h5py
76
+ from ldpart import partition_by_determinant, labels_to_blocks
77
+
78
+ with h5py.File("ld.h5", "r") as f:
79
+ matrix = f["r2"]
80
+ labels, nblocks = partition_by_determinant(matrix, min_det=0.001)
81
+
82
+ blocks = labels_to_blocks(labels) # [(block, [snp indexes]), ...]
83
+ ```
84
+
85
+ Every function returns `(labels, nblocks)`, where labels is a per-SNP
86
+ list of `[index, block]` with blocks numbered from 1.
87
+
88
+ ## Command line
89
+
90
+ ```bash
91
+ ldpart -i ld.h5 -o labels.txt # det 0.001
92
+ ldpart -i ld.h5 -o labels.txt -m condition -c 1000
93
+ ldpart -i ld.h5 -o labels.txt -m vif -v 10
94
+ ldpart -i ld.h5 -o labels.txt -m reciprocal -r 5
95
+ ldpart -i ld.h5 -o labels.txt -m pairwise -p 0.8
96
+ ```
97
+
98
+ The output has two columns, the zero-based SNP index and the block
99
+ label.
100
+
101
+ ## Semantics
102
+
103
+ - **Greedy growth:** the block starting at SNP i1 is extended by the
104
+ candidate SNP j only while the criterion holds over the window
105
+ matrix[i1:j+1, i1:j+1] *including the candidate*; the first failing
106
+ candidate seeds the next block, so blocks are contiguous and every
107
+ block satisfies its criterion at its final size and at every prefix.
108
+ - **Certified maximality:** the determinant can only decrease when a
109
+ SNP is added (Schur complement: each step multiplies it by
110
+ 1 − R² ≤ 1), the condition number cannot recover (Cauchy
111
+ interlacing), the maximal VIF cannot recover (nested regressions) —
112
+ so the greedy cut yields the largest admissible block. The sum of
113
+ reciprocal eigenvalues per window size can recover, so its blocks
114
+ are certified but not maximal.
115
+ - **NaN semantics:** a NaN anywhere in the candidate window (or the
116
+ candidate's row segment for the pairwise screen) fails the
117
+ extension — NaN never silently extends a block. A SNP with a NaN
118
+ diagonal stays a singleton seed.
119
+
120
+ ## Testing
121
+
122
+ ```bash
123
+ pip install -e ".[test]"
124
+ pytest tests
125
+ ```
126
+
127
+ The suite holds property tests on random correlation matrices:
128
+ certification of every block by independent LAPACK recomputation,
129
+ maximality of the monotone criteria against brute force, the NaN
130
+ semantics, fast/slogdet agreement, numpy/h5py equivalence, and the
131
+ command line interface end to end.
132
+
133
+ ## References
134
+
135
+ - Rogers & Huff (2008), *Heredity* — the r² estimator used in the
136
+ companion study.
137
+ - Belsley, Kuh & Welsch (1980) — regression diagnostics, condition
138
+ indices.
139
+ - Chatterjee & Price (1977); Dillon & Goldstein (1984) — the
140
+ sum-of-reciprocal-eigenvalues rule.
141
+ - Khvorykh, G., Khrunin, A., 2025. Determinant-based grouping of SNPs and its application for detecting disease-associated genomic loci. NAR Genomics and Bioinformatics 7. https://doi.org/10.1093/nargab/lqaf024
142
+ - The method is developed and validated in the LD matrix partition study (in preparation)
143
+
144
+
145
+ ## License
146
+
147
+ MIT © Gennady Khvorykh
148
+
149
+ ## Author
150
+
151
+ Gennady Khvorykh, info [at] inzilico.com
@@ -0,0 +1,10 @@
1
+ ldpart/__init__.py,sha256=0cYMpErfW5rhp4RQIMkEXL2Ga3-mmdE9PCcA_mlj2qY,1173
2
+ ldpart/cli.py,sha256=2AikozwXofKqa5z27x9e_wHW4NTekxsVqBhjJ6Yo9u4,4663
3
+ ldpart/helpers.py,sha256=JN2N1cg0_Judf50s2iUXBvfJssdr8nPC6JNoLxapCqQ,1601
4
+ ldpart/partitioner.py,sha256=0OSkPggAz6M9WS6okW-hef169E5hKGCVX_R8Nf_mElg,19345
5
+ ldpart-0.1.0.dist-info/licenses/LICENSE,sha256=AT8YUEsBDEKDq18sv0PVhMSX5DTd2qsDXVcAvqSViIQ,1073
6
+ ldpart-0.1.0.dist-info/METADATA,sha256=fB6xDP38wQihb6WHJ7A1N9NSQZZQBb3wAolhPBT9yAE,6169
7
+ ldpart-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ ldpart-0.1.0.dist-info/entry_points.txt,sha256=uwyEC-GYQIf4LaUkbEuWQafQVWjWaD2Gb5Sx4jhKgzs,43
9
+ ldpart-0.1.0.dist-info/top_level.txt,sha256=lQ0qqB2fAqWVrCR3-MVHn17FH0uUv6gOE-5pTM43MNo,7
10
+ ldpart-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ldpart = ldpart.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gennady Khvorykh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ldpart