ldclust 0.2.0__tar.gz

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.
ldclust-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: ldclust
3
+ Version: 0.2.0
4
+ Summary: LD-based clustering of SNPs with a haplotype-testing protocol
5
+ Author-email: Gennady Khvorykh <info@inzilico.com>
6
+ Keywords: linkage disequilibrium,SNP,clustering,haplotype,bioinformatics
7
+ Classifier: Intended Audience :: Science/Research
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: numpy>=1.24
13
+ Requires-Dist: scipy>=1.10
14
+ Requires-Dist: h5py
15
+ Requires-Dist: pandas
16
+ Requires-Dist: scikit-learn>=1.3
17
+ Provides-Extra: hdbscan
18
+ Requires-Dist: hdbscan>=0.8; extra == "hdbscan"
19
+ Provides-Extra: graph
20
+ Requires-Dist: networkx>=3; extra == "graph"
21
+ Provides-Extra: igraph
22
+ Requires-Dist: igraph; extra == "igraph"
23
+ Requires-Dist: leidenalg; extra == "igraph"
24
+ Requires-Dist: infomap; extra == "igraph"
25
+ Provides-Extra: all
26
+ Requires-Dist: hdbscan>=0.8; extra == "all"
27
+ Requires-Dist: networkx>=3; extra == "all"
28
+ Requires-Dist: igraph; extra == "all"
29
+ Requires-Dist: leidenalg; extra == "all"
30
+ Requires-Dist: infomap; extra == "all"
31
+
32
+ # ldclust
33
+
34
+ LD-based clustering of SNPs with a haplotype-testing protocol: load an
35
+ LD (r²) matrix, define distances or a spectral embedding, cluster SNPs
36
+ into LD blocks, write PLINK `--hap-assoc` hlist files, run the
37
+ haplotype-association endpoint, and select associated blocks.
38
+
39
+ The package grew out of a benchmark of 25 clustering method families on
40
+ LD matrices (simulated and real chromosome 22); every wrapper returns
41
+ `(labels, probs_or_None, seconds)` and consumes one of four substrates,
42
+ so methods are drop-in comparable.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install . # core: numpy, scipy, h5py, pandas, scikit-learn
48
+ pip install ".[all]" # + hdbscan, networkx, igraph, leidenalg, infomap
49
+ ```
50
+
51
+ The SBM family needs [graph-tool](https://graph-tool.skewed.de), which
52
+ is not pip-installable:
53
+
54
+ ```bash
55
+ conda create -n sbm -c conda-forge python=3.10 numpy scipy h5py graph-tool
56
+ conda run -n sbm python -m ldclust.cli --help
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ Command line (one clustering run + benchmark artifacts: blocks csv,
62
+ labels npy, PLINK hlist, `summary.tsv` row):
63
+
64
+ ```bash
65
+ ldclust -p <prefix> --method fof --eps 0.5 --out-dir data/bench
66
+ ldclust -p <prefix> --method hc --deep-split 2 --min-cluster-size 3
67
+ ldclust -p <prefix> --method dpblocks --tag-thr 0.3 --max-len 30
68
+ ldclust --help
69
+ ```
70
+
71
+ `<prefix>` names `prefix.ld.h5` + `prefix.snplist` (an r² matrix with
72
+ SNP ids; pandas-fixed and plain h5py layouts both load); the `gmm` /
73
+ `dpgmm` methods additionally read `<prefix>.gmm.raw` (PLINK `--recodeA`
74
+ dosages) to build the relationship-matrix embedding.
75
+
76
+ Library:
77
+
78
+ ```python
79
+ import ldclust as ld
80
+
81
+ ids, r2, layout = ld.load_ld(prefix) # SNP ids + r2 matrix
82
+ labels, _, sec = ld.cluster_hc(ld.distances(r2, "d1"),
83
+ deep_split=2) # WGCNA-style, dynamic tree cut
84
+ ld.write_hlist("blocks.hlist", ids, labels) # PLINK --hap input
85
+ assoc = ld.run_hap_assoc(prefix, "blocks.hlist", "run1",
86
+ timeout_sec=600) # PLINK 1.07 --hap-assoc
87
+ selected = ld.select_blocks(ld.parse_assoc_hap(assoc)) # OMNIBUS p <= 5e-6
88
+ ```
89
+
90
+ ## Methods (25 families)
91
+
92
+ | substrate | methods |
93
+ |---|---|
94
+ | precomputed distances (1 − r², √(1 − r²)) | hdbscan, optics, dbscan |
95
+ | spectral embeddings of r² / relationship matrix | soptics, spectral (NJW), gmm, dpgmm, kmeans, minibatch |
96
+ | weighted r² graph | fof (percolation), louvain, leiden, cnm, mcl, lpa, cw (Chinese Whispers), walktrap, infomap, sbm (DC/nested, MDL) |
97
+ | raw r² similarity | ap (affinity propagation), pam (similarity k-medoids), cdhit (CD-HIT-style seed-and-recruit) |
98
+ | hierarchical | hc (average/complete linkage + dynamic tree cut, WGCNA recipe) |
99
+ | ordered SNPs | dpblocks (Zhang et al. 2002 DP, tag-SNP cost) |
100
+ | ensembles | consensus (co-association: DTC on 1 − C or percolation on C) |
101
+
102
+ Noise label convention: −1 where a method defines it (hdbscan family,
103
+ dynamic tree cut); all other methods assign every SNP. Every wrapper is
104
+ deterministic unless documented (gmm/dpgmm/sbm/minibatch are seeded).
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ python3 tests/test_smoke.py # synthetic-matrix smoke tests, no PLINK
110
+ ```
111
+
112
+ Author: Gennady Khvorykh (`info@inzilico.com`).
@@ -0,0 +1,81 @@
1
+ # ldclust
2
+
3
+ LD-based clustering of SNPs with a haplotype-testing protocol: load an
4
+ LD (r²) matrix, define distances or a spectral embedding, cluster SNPs
5
+ into LD blocks, write PLINK `--hap-assoc` hlist files, run the
6
+ haplotype-association endpoint, and select associated blocks.
7
+
8
+ The package grew out of a benchmark of 25 clustering method families on
9
+ LD matrices (simulated and real chromosome 22); every wrapper returns
10
+ `(labels, probs_or_None, seconds)` and consumes one of four substrates,
11
+ so methods are drop-in comparable.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install . # core: numpy, scipy, h5py, pandas, scikit-learn
17
+ pip install ".[all]" # + hdbscan, networkx, igraph, leidenalg, infomap
18
+ ```
19
+
20
+ The SBM family needs [graph-tool](https://graph-tool.skewed.de), which
21
+ is not pip-installable:
22
+
23
+ ```bash
24
+ conda create -n sbm -c conda-forge python=3.10 numpy scipy h5py graph-tool
25
+ conda run -n sbm python -m ldclust.cli --help
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Command line (one clustering run + benchmark artifacts: blocks csv,
31
+ labels npy, PLINK hlist, `summary.tsv` row):
32
+
33
+ ```bash
34
+ ldclust -p <prefix> --method fof --eps 0.5 --out-dir data/bench
35
+ ldclust -p <prefix> --method hc --deep-split 2 --min-cluster-size 3
36
+ ldclust -p <prefix> --method dpblocks --tag-thr 0.3 --max-len 30
37
+ ldclust --help
38
+ ```
39
+
40
+ `<prefix>` names `prefix.ld.h5` + `prefix.snplist` (an r² matrix with
41
+ SNP ids; pandas-fixed and plain h5py layouts both load); the `gmm` /
42
+ `dpgmm` methods additionally read `<prefix>.gmm.raw` (PLINK `--recodeA`
43
+ dosages) to build the relationship-matrix embedding.
44
+
45
+ Library:
46
+
47
+ ```python
48
+ import ldclust as ld
49
+
50
+ ids, r2, layout = ld.load_ld(prefix) # SNP ids + r2 matrix
51
+ labels, _, sec = ld.cluster_hc(ld.distances(r2, "d1"),
52
+ deep_split=2) # WGCNA-style, dynamic tree cut
53
+ ld.write_hlist("blocks.hlist", ids, labels) # PLINK --hap input
54
+ assoc = ld.run_hap_assoc(prefix, "blocks.hlist", "run1",
55
+ timeout_sec=600) # PLINK 1.07 --hap-assoc
56
+ selected = ld.select_blocks(ld.parse_assoc_hap(assoc)) # OMNIBUS p <= 5e-6
57
+ ```
58
+
59
+ ## Methods (25 families)
60
+
61
+ | substrate | methods |
62
+ |---|---|
63
+ | precomputed distances (1 − r², √(1 − r²)) | hdbscan, optics, dbscan |
64
+ | spectral embeddings of r² / relationship matrix | soptics, spectral (NJW), gmm, dpgmm, kmeans, minibatch |
65
+ | weighted r² graph | fof (percolation), louvain, leiden, cnm, mcl, lpa, cw (Chinese Whispers), walktrap, infomap, sbm (DC/nested, MDL) |
66
+ | raw r² similarity | ap (affinity propagation), pam (similarity k-medoids), cdhit (CD-HIT-style seed-and-recruit) |
67
+ | hierarchical | hc (average/complete linkage + dynamic tree cut, WGCNA recipe) |
68
+ | ordered SNPs | dpblocks (Zhang et al. 2002 DP, tag-SNP cost) |
69
+ | ensembles | consensus (co-association: DTC on 1 − C or percolation on C) |
70
+
71
+ Noise label convention: −1 where a method defines it (hdbscan family,
72
+ dynamic tree cut); all other methods assign every SNP. Every wrapper is
73
+ deterministic unless documented (gmm/dpgmm/sbm/minibatch are seeded).
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ python3 tests/test_smoke.py # synthetic-matrix smoke tests, no PLINK
79
+ ```
80
+
81
+ Author: Gennady Khvorykh (`info@inzilico.com`).
@@ -0,0 +1,20 @@
1
+ """ldclust: LD-based SNP clustering + haplotype-testing protocol."""
2
+
3
+ from .library import (PROTOCOL_P, DISTANCES, load_ld, load_ld_h5, load_ids,
4
+ drop_nan_snps, distances, spectral_embedding,
5
+ cluster_hdbscan, cluster_optics, cluster_soptics,
6
+ cluster_dbscan, cluster_fof, cluster_louvain,
7
+ spectral_eig, cluster_spectral, cluster_ap,
8
+ cluster_mcl, cluster_lpa, cluster_leiden,
9
+ cluster_walktrap, cluster_infomap, cluster_pam,
10
+ cluster_cnm, cluster_cw, cluster_kmeans,
11
+ cluster_minibatch_kmeans, load_dosage,
12
+ cross_product_matrix, cluster_gmm, cluster_hc,
13
+ dynamic_tree_cut, cluster_dpblocks, cluster_sbm,
14
+ cluster_cdhit, coassociation, cluster_consensus,
15
+ cluster_dpgmm, write_blocks,
16
+ write_hdbscan_file, write_hlist, run_hap_assoc,
17
+ parse_assoc_hap, select_blocks, causal_snps,
18
+ snp_positions, clustering_stats, pairwise_ari)
19
+
20
+ __version__ = "0.2.0"
@@ -0,0 +1,431 @@
1
+ """
2
+ ldclust command line: run one clustering method on an LD matrix and
3
+ write the benchmark artifacts (blocks csv, labels npy, hlist for PLINK
4
+ --hap-assoc, and a row in <out-dir>/summary.tsv).
5
+
6
+ Methods:
7
+ hdbscan HDBSCAN on precomputed distances (--dist d1|d2, default d2)
8
+ optics OPTICS on precomputed distances d2 (sklearn)
9
+ soptics OPTICS on the spectral embedding of the r2 matrix
10
+ fof friends-of-friends percolation on r2 >= --eps (no noise label)
11
+ dbscan DBSCAN on precomputed distances d2 with --eps
12
+ louvain modularity communities on the weighted r2 graph
13
+ spectral normalized-affinity spectral clustering (Ng et al. 2002)
14
+ ap affinity propagation on the raw r2 similarity
15
+ mcl Markov clustering on the weighted r2 graph
16
+ lpa asynchronous label propagation on the r2 graph
17
+ leiden Leiden RBConfiguration partition on the r2 graph
18
+ walktrap Walktrap communities on the r2 graph
19
+ infomap Infomap two-level partition on the r2 graph
20
+ pam similarity k-medoids on the raw r2
21
+ cnm greedy modularity (CNM) on the r2 graph
22
+ cw Chinese Whispers on the r2 graph
23
+ gmm Gaussian mixture on the cross-product-matrix embedding
24
+ kmeans K-Means on the Euclidean embedding of 1 - r2
25
+ minibatch Mini-Batch K-Means on the same embedding
26
+ hc agglomerative linkage on 1-r2 + dynamic tree cut (noise = -1)
27
+ dpblocks dynamic-programming contiguous blocks minimizing tag SNPs
28
+ sbm degree-corrected stochastic block model (needs graph-tool;
29
+ run inside its conda environment - see README)
30
+ cdhit CD-HIT-style greedy seed-and-recruit on r2 >= --eps
31
+ consensus evidence-accumulation consensus of existing runs' labels
32
+ (--bases tag1,tag2,... --consensus dtc|fof)
33
+ dpgmm Dirichlet-process GMM on the gmm embedding (--k = cap only)
34
+
35
+ Usage:
36
+ ldclust -p <prefix> --method <name> [--eps 0.5] [--dist d2]
37
+ [--out-dir data/bench] [--check <labels.npy>]
38
+ (prefix names prefix.ld.h5 + prefix.snplist; --method hdbscan|gmm
39
+ also read prefix.gmm.raw via PLINK --recodeA output)
40
+
41
+ Author: Gennady Khvorykh, info@inzilico.com
42
+ Started: 2026-09-21
43
+ """
44
+
45
+ import argparse
46
+ import os
47
+ import time
48
+
49
+ import numpy as np
50
+
51
+ from . import __version__
52
+ from . import library as ld
53
+
54
+
55
+ def build_parser():
56
+ parser = argparse.ArgumentParser(
57
+ prog="ldclust",
58
+ description="LD-based clustering runner (ldclust package)")
59
+ parser.add_argument("-p", "--prefix", required=True,
60
+ help="/path/to/prefix of prefix.ld.h5 file")
61
+ parser.add_argument("--method", required=True,
62
+ choices=["hdbscan", "optics", "soptics", "fof",
63
+ "dbscan", "louvain", "spectral", "ap",
64
+ "mcl", "lpa", "leiden", "walktrap",
65
+ "infomap", "pam", "cnm", "cw", "gmm",
66
+ "kmeans", "minibatch", "hc", "dpblocks",
67
+ "sbm", "cdhit", "consensus", "dpgmm"])
68
+ parser.add_argument("--dist", choices=["d1", "d2"], default="d2")
69
+ parser.add_argument("--eps", type=float, default=None,
70
+ help="threshold for fof and cdhit (r2), dbscan "
71
+ "(distance), consensus-fof (co-association)")
72
+ parser.add_argument("--min-samples", type=int, default=5)
73
+ parser.add_argument("--xi", type=float, default=0.05)
74
+ parser.add_argument("--variance", type=float, default=0.95)
75
+ parser.add_argument("--k", type=int, default=None,
76
+ help="components: soptics embedding dim / pam "
77
+ "blocks / gmm components / dpgmm cap")
78
+ parser.add_argument("--dim", type=int, default=50,
79
+ help="embedding dimension for gmm/dpgmm")
80
+ parser.add_argument("--gamma", type=float, default=None,
81
+ help="resolution for louvain")
82
+ parser.add_argument("--theta", type=float, default=None,
83
+ help="eigenvalue cutoff for spectral "
84
+ "(k = #(w > theta))")
85
+ parser.add_argument("--preference", type=float, default=None,
86
+ help="self-similarity (diagonal) for ap")
87
+ parser.add_argument("--inflation", type=float, default=None,
88
+ help="inflation for mcl")
89
+ parser.add_argument("--cutoff", type=float, default=0.05,
90
+ help="edge cutoff on r2 for graph methods; "
91
+ "consensus bases count; sbm")
92
+ parser.add_argument("--steps", type=int, default=4,
93
+ help="random-walk length for walktrap")
94
+ parser.add_argument("--linkage", choices=["average", "complete"],
95
+ default="average", help="agglomeration for hc")
96
+ parser.add_argument("--min-cluster-size", type=int, default=3,
97
+ help="dynamic tree cut minimum block size for hc")
98
+ parser.add_argument("--deep-split", type=int, default=None,
99
+ choices=[0, 1, 2, 3, 4],
100
+ help="dynamic tree cut deepSplit preset for hc")
101
+ parser.add_argument("--tag-thr", type=float, default=None,
102
+ help="r2 tag-coverage threshold for dpblocks")
103
+ parser.add_argument("--max-len", type=int, default=None,
104
+ help="maximum block length in SNPs for dpblocks")
105
+ parser.add_argument("--no-deg-corr", action="store_true",
106
+ help="plain SBM (no degree correction) for sbm")
107
+ parser.add_argument("--binary", action="store_true",
108
+ help="ignore r2 weights (binary graph) for sbm")
109
+ parser.add_argument("--nested", action="store_true",
110
+ help="nested SBM (level-0 partition) for sbm")
111
+ parser.add_argument("--priority", choices=["degree", "order"],
112
+ default="degree", help="seed priority for cdhit")
113
+ parser.add_argument("--recruit", choices=["seed", "member"],
114
+ default="seed", help="recruit semantics for cdhit")
115
+ parser.add_argument("--bases", default=None,
116
+ help="comma-separated base run tags for consensus "
117
+ "(labels.npy in --out-dir)")
118
+ parser.add_argument("--consensus", choices=["dtc", "fof"], default="dtc",
119
+ help="consensus function on the co-association "
120
+ "matrix")
121
+ parser.add_argument("--conc", type=float, default=None,
122
+ help="DP weight concentration prior for dpgmm "
123
+ "(default: sklearn 1/cap)")
124
+ parser.add_argument("--out-dir", default="data/bench")
125
+ parser.add_argument("--check", default=None,
126
+ help="optional labels.npy to verify determinism")
127
+ parser.add_argument("--version", action="version",
128
+ version="%(prog)s " + __version__)
129
+ return parser
130
+
131
+
132
+ def main(argv=None):
133
+ t1 = time.time()
134
+ parser = build_parser()
135
+ args = parser.parse_args(argv)
136
+
137
+ if args.method == "fof" and args.eps is None:
138
+ parser.error("--eps is required for fof")
139
+ if args.method == "dbscan" and args.eps is None:
140
+ parser.error("--eps is required for dbscan")
141
+ if args.method == "louvain" and args.gamma is None:
142
+ parser.error("--gamma is required for louvain")
143
+ if args.method == "spectral" and args.theta is None:
144
+ parser.error("--theta is required for spectral")
145
+ if args.method == "ap" and args.preference is None:
146
+ parser.error("--preference is required for ap")
147
+ if args.method == "mcl" and args.inflation is None:
148
+ parser.error("--inflation is required for mcl")
149
+ if args.method == "leiden" and args.gamma is None:
150
+ parser.error("--gamma is required for leiden")
151
+ if args.method == "pam" and args.k is None:
152
+ parser.error("--k is required for pam")
153
+ if args.method == "gmm" and args.k is None:
154
+ parser.error("--k is required for gmm")
155
+ if args.method == "kmeans" and args.k is None:
156
+ parser.error("--k is required for kmeans")
157
+ if args.method == "minibatch" and args.k is None:
158
+ parser.error("--k is required for minibatch")
159
+ if args.method == "dpgmm" and args.k is None:
160
+ parser.error("--k (component cap) is required for dpgmm")
161
+ if args.method == "hc" and args.deep_split is None:
162
+ parser.error("--deep-split is required for hc")
163
+ if args.method == "cdhit" and args.eps is None:
164
+ parser.error("--eps is required for cdhit")
165
+ if args.method == "consensus" and not args.bases:
166
+ parser.error("--bases is required for consensus")
167
+ if (args.method == "consensus" and args.consensus == "fof"
168
+ and args.eps is None):
169
+ parser.error("--eps (co-association threshold) is required for "
170
+ "fof consensus")
171
+ if args.method == "dpblocks" and (args.tag_thr is None
172
+ or args.max_len is None):
173
+ parser.error("--tag-thr and --max-len are required for dpblocks")
174
+
175
+ os.makedirs(args.out_dir, exist_ok=True)
176
+ name = os.path.basename(args.prefix)
177
+
178
+ ids, r2, layout = ld.load_ld(args.prefix)
179
+ r2, ids, n_dropped = ld.drop_nan_snps(r2, ids)
180
+ if n_dropped:
181
+ print(f"Dropped {n_dropped} SNPs with NaN")
182
+ print(f"Loaded {ids.size} SNPs ({layout})")
183
+
184
+ substrate = "precomputed-" + args.dist
185
+ if args.method == "hdbscan":
186
+ labels, probs, sec = ld.cluster_hdbscan(ld.distances(r2, args.dist))
187
+ tag = f"hdbscan.{args.dist}"
188
+ elif args.method == "optics":
189
+ labels, probs, sec = ld.cluster_optics(ld.distances(r2, "d2"),
190
+ args.min_samples, args.xi)
191
+ tag = "optics"
192
+ elif args.method == "soptics":
193
+ ksfx = f".emb{args.k}" if args.k else ""
194
+ emb_file = os.path.join(args.out_dir, f"{name}{ksfx}.embedding.npy")
195
+ if os.path.isfile(emb_file):
196
+ coords = np.load(emb_file)
197
+ print(f"Loaded embedding from {emb_file} (k = {coords.shape[1]})")
198
+ else:
199
+ coords, k, _ = ld.spectral_embedding(r2, args.variance, args.k)
200
+ np.save(emb_file, coords)
201
+ print(f"Built spectral embedding (k = {k}), saved to {emb_file}")
202
+ labels, probs, sec = ld.cluster_soptics(coords, args.min_samples,
203
+ args.xi)
204
+ tag = "soptics" + (f"k{args.k}" if args.k else "")
205
+ substrate = "spectral-embedding"
206
+ elif args.method == "fof":
207
+ labels, probs, sec = ld.cluster_fof(r2, args.eps)
208
+ tag = f"fof{args.eps}"
209
+ substrate = "r2-graph"
210
+ elif args.method == "louvain":
211
+ labels, probs, sec = ld.cluster_louvain(r2, args.gamma)
212
+ tag = f"louvain{args.gamma}"
213
+ substrate = "r2-graph"
214
+ elif args.method == "spectral":
215
+ labels, probs, sec = ld.cluster_spectral(r2, args.theta)
216
+ tag = f"spectral{args.theta}"
217
+ substrate = "normalized-affinity"
218
+ elif args.method == "ap":
219
+ labels, probs, sec = ld.cluster_ap(r2, args.preference)
220
+ tag = f"ap{args.preference}"
221
+ substrate = "r2-similarity"
222
+ elif args.method == "mcl":
223
+ labels, probs, sec = ld.cluster_mcl(r2, args.inflation, args.cutoff)
224
+ tag = f"mcl{args.inflation}"
225
+ substrate = "r2-graph"
226
+ elif args.method == "lpa":
227
+ labels, probs, sec = ld.cluster_lpa(r2, args.cutoff)
228
+ tag = "lpa"
229
+ substrate = "r2-graph"
230
+ elif args.method == "leiden":
231
+ labels, probs, sec = ld.cluster_leiden(r2, args.gamma, args.cutoff)
232
+ tag = f"leiden{args.gamma}"
233
+ substrate = "r2-graph"
234
+ elif args.method == "walktrap":
235
+ labels, probs, sec = ld.cluster_walktrap(r2, args.steps, args.cutoff)
236
+ tag = f"walktrap{args.steps}"
237
+ substrate = "r2-graph"
238
+ elif args.method == "infomap":
239
+ labels, probs, sec = ld.cluster_infomap(r2, args.cutoff)
240
+ tag = "infomap"
241
+ substrate = "r2-graph"
242
+ elif args.method == "pam":
243
+ labels, probs, sec = ld.cluster_pam(r2, args.k)
244
+ tag = f"pam{args.k}"
245
+ substrate = "r2-similarity"
246
+ elif args.method == "cnm":
247
+ labels, probs, sec = ld.cluster_cnm(r2, args.cutoff)
248
+ tag = "cnm"
249
+ substrate = "r2-graph"
250
+ elif args.method == "cw":
251
+ labels, probs, sec = ld.cluster_cw(r2, args.cutoff)
252
+ tag = "cw"
253
+ substrate = "r2-graph"
254
+ elif args.method == "gmm":
255
+ # relationship matrix from the genotype dosages, then GMM on
256
+ # its spectral embedding with a full per-component covariance
257
+ gmm_prefix = args.prefix + ".gmm" # genotype prefix must expose .raw
258
+ emb_file = os.path.join(args.out_dir,
259
+ f"{name}.gmm{args.dim}.embedding.npy")
260
+ if os.path.isfile(emb_file):
261
+ coords = np.load(emb_file)
262
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
263
+ else:
264
+ k_grm, raw_snps = ld.cross_product_matrix(gmm_prefix)
265
+ if not np.array_equal(raw_snps, ids):
266
+ print("Cross-product SNP order differs from snplist - "
267
+ "reordering")
268
+ pos_of = {s: i for i, s in enumerate(raw_snps)}
269
+ order = np.array([pos_of[s] for s in ids])
270
+ k_grm = k_grm[np.ix_(order, order)]
271
+ coords, dim_used, _ = ld.spectral_embedding(k_grm, k=args.dim)
272
+ del k_grm
273
+ np.save(emb_file, coords)
274
+ print(f"Built cross-product embedding (d = {dim_used}), "
275
+ f"saved to {emb_file}")
276
+ labels, probs, sec = ld.cluster_gmm(k=args.k, dim=args.dim,
277
+ coords=coords)
278
+ tag = f"gmm{args.k}d{args.dim}"
279
+ substrate = "cross-product-embedding"
280
+ elif args.method == "kmeans":
281
+ # K-Means on the Euclidean embedding of the distance-converted LD
282
+ # matrix (d = 1 - r2 is squared Euclidean; classical MDS coords)
283
+ emb_file = os.path.join(
284
+ args.out_dir, f"{name}.kmeans{args.dim}.embedding.npy")
285
+ if os.path.isfile(emb_file):
286
+ coords = np.load(emb_file)
287
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
288
+ else:
289
+ coords, dim_used, _ = ld.spectral_embedding(r2, k=args.dim)
290
+ np.save(emb_file, coords)
291
+ print(f"Built r2 embedding (d = {dim_used}), saved to {emb_file}")
292
+ labels, probs, sec = ld.cluster_kmeans(r2, args.k, args.dim)
293
+ tag = f"kmeans{args.k}d{args.dim}"
294
+ substrate = "r2-embedding"
295
+ elif args.method == "minibatch":
296
+ # Mini-Batch K-Means on the same embedding as kmeans (shared cache)
297
+ emb_file = os.path.join(
298
+ args.out_dir, f"{name}.kmeans{args.dim}.embedding.npy")
299
+ if os.path.isfile(emb_file):
300
+ coords = np.load(emb_file)
301
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
302
+ else:
303
+ coords, dim_used, _ = ld.spectral_embedding(r2, k=args.dim)
304
+ np.save(emb_file, coords)
305
+ print(f"Built r2 embedding (d = {dim_used}), saved to {emb_file}")
306
+ labels, probs, sec = ld.cluster_minibatch_kmeans(r2, args.k, args.dim)
307
+ tag = f"mbk{args.k}d{args.dim}"
308
+ substrate = "r2-embedding"
309
+ elif args.method == "hc":
310
+ # WGCNA-style: scipy linkage on the condensed d1 = 1 - r2 (the
311
+ # analogue of WGCNA's 1 - |cor|), then the dynamic tree cut
312
+ # (Langfelder et al. 2008, tree stage) on the dendrogram
313
+ labels, probs, sec = ld.cluster_hc(ld.distances(r2, "d1"),
314
+ args.linkage,
315
+ args.min_cluster_size,
316
+ args.deep_split)
317
+ tag = f"dtc{args.deep_split}" + ("" if args.linkage == "average"
318
+ else args.linkage[0])
319
+ substrate = "precomputed-d1"
320
+ elif args.method == "dpblocks":
321
+ # Zhang et al. 2002 DP framework, r2-tagging cost: optimal
322
+ # contiguous partition of the ordered SNPs minimizing total tag
323
+ # SNPs (every SNP covered at r2 >= tag_thr; greedy set cover per
324
+ # candidate block)
325
+ labels, probs, sec = ld.cluster_dpblocks(r2, args.tag_thr,
326
+ args.max_len)
327
+ tag = f"dp{args.tag_thr}L{args.max_len}"
328
+ substrate = "r2-ordered"
329
+ elif args.method == "sbm":
330
+ # degree-corrected SBM on the weighted r2 graph (Peixoto 2017 MDL,
331
+ # graph-tool); run this CLI inside the graph-tool environment
332
+ labels, probs, sec = ld.cluster_sbm(r2, args.cutoff,
333
+ deg_corr=not args.no_deg_corr,
334
+ weighted=not args.binary,
335
+ nested=args.nested)
336
+ tag = ("sbm" + ("nd" if args.no_deg_corr else "")
337
+ + ("b" if args.binary else "w") + str(args.cutoff)
338
+ + ("n" if args.nested else ""))
339
+ substrate = "r2-graph-mdl"
340
+ elif args.method == "cdhit":
341
+ # CD-HIT-style greedy seed-and-recruit: hub SNPs (weighted degree)
342
+ # seed blocks first, recruits need r2 >= eps to the seed (no
343
+ # transitive chaining - the anti-FoF)
344
+ labels, probs, sec = ld.cluster_cdhit(r2, args.eps, args.priority,
345
+ args.recruit)
346
+ tag = (f"cd{args.eps}" + ("o" if args.priority == "order" else "")
347
+ + ("m" if args.recruit == "member" else ""))
348
+ substrate = "r2-similarity"
349
+ elif args.method == "consensus":
350
+ # evidence-accumulation consensus: co-association matrix of the
351
+ # base partitions, then DTC on 1 - C (C is PSD like r2) or
352
+ # percolation
353
+ bases = args.bases.split(",")
354
+ base_labels = [np.load(os.path.join(
355
+ args.out_dir, f"{name}.{b}.labels.npy")) for b in bases]
356
+ ds = args.deep_split if args.deep_split is not None else 2
357
+ labels, probs, sec = ld.cluster_consensus(
358
+ base_labels, method=args.consensus, thr=args.eps, deep_split=ds,
359
+ min_cluster_size=args.min_cluster_size)
360
+ if args.consensus == "dtc":
361
+ tag = f"consdtc{ds}"
362
+ else:
363
+ tag = f"consfof{args.eps:g}"
364
+ substrate = "coassociation-of:" + "+".join(bases)
365
+ elif args.method == "dpgmm":
366
+ # Dirichlet-process GMM on the same cached embedding as gmm; --k is
367
+ # only an upper bound - the DP empties unsupported components
368
+ emb_file = os.path.join(
369
+ args.out_dir, f"{name}.gmm{args.dim}.embedding.npy")
370
+ if os.path.isfile(emb_file):
371
+ coords = np.load(emb_file)
372
+ print(f"Loaded embedding from {emb_file} (d = {coords.shape[1]})")
373
+ else:
374
+ gmm_prefix = args.prefix + ".gmm"
375
+ k_grm, raw_snps = ld.cross_product_matrix(gmm_prefix)
376
+ if not np.array_equal(raw_snps, ids):
377
+ print("Cross-product SNP order differs from snplist - "
378
+ "reordering")
379
+ pos_of = {s: i for i, s in enumerate(raw_snps)}
380
+ order = np.array([pos_of[s] for s in ids])
381
+ k_grm = k_grm[np.ix_(order, order)]
382
+ coords, dim_used, _ = ld.spectral_embedding(k_grm, k=args.dim)
383
+ del k_grm
384
+ np.save(emb_file, coords)
385
+ print(f"Built cross-product embedding (d = {dim_used}), "
386
+ f"saved to {emb_file}")
387
+ labels, probs, sec = ld.cluster_dpgmm(
388
+ coords=coords, n_components=args.k, dim=args.dim,
389
+ weight_concentration_prior=args.conc)
390
+ tag = f"dpgmm{args.k}" + (f"a{args.conc:g}" if args.conc else "adef")
391
+ substrate = "cross-product-embedding"
392
+ elif args.method == "dbscan":
393
+ labels, probs, sec = ld.cluster_dbscan(ld.distances(r2, "d2"),
394
+ args.eps, args.min_samples)
395
+ tag = f"dbscan{args.eps}"
396
+
397
+ stats = ld.clustering_stats(labels)
398
+ print(f"{tag}: {stats['n_blocks']} blocks, noise {stats['noise']:.2%}, "
399
+ f"median size {stats['size_median']}, max {stats['size_max']}, "
400
+ f"{sec:.0f} s")
401
+
402
+ if args.check:
403
+ ref = np.load(args.check)
404
+ if ref.size == labels.size:
405
+ print(f"Determinism check vs {args.check}: "
406
+ f"{int((ref != labels).sum())} label differences")
407
+
408
+ ld.write_blocks(os.path.join(args.out_dir, f"{name}.{tag}.blocks.csv"),
409
+ ids, labels)
410
+ np.save(os.path.join(args.out_dir, f"{name}.{tag}.labels.npy"), labels)
411
+ ld.write_hlist(os.path.join(args.out_dir, f"{name}.{tag}.hlist"),
412
+ ids, labels)
413
+
414
+ row = {"method": args.method, "tag": tag, "substrate": substrate,
415
+ "params": f"eps={args.eps};min_samples={args.min_samples};"
416
+ f"xi={args.xi};dist={args.dist}", **stats,
417
+ "seconds": sec}
418
+ summary = os.path.join(args.out_dir, "summary.tsv")
419
+ header = not os.path.isfile(summary)
420
+ with open(summary, "a") as f:
421
+ if header:
422
+ f.write("\t".join(row.keys()) + "\n")
423
+ f.write("\t".join(str(v) for v in row.values()) + "\n")
424
+ print("Appended summary row to", summary)
425
+ dur = time.strftime("%H:%M:%S", time.gmtime(time.time() - t1))
426
+ print(f"Done {tag}, time elapsed: {dur}")
427
+ return 0
428
+
429
+
430
+ if __name__ == "__main__":
431
+ raise SystemExit(main())