pycopykat 0.2.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.
pycopykat/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """pycopykat: a Python implementation of CopyKAT (Gao et al. 2021) that reproduces R copykat."""
2
+ from pycopykat.config import CopykatConfig
3
+ from pycopykat.io.h5ad import copykat_by_batch
4
+ from pycopykat.pipeline import copykat
5
+ from pycopykat.result import CopykatResult
6
+
7
+ __all__ = ["CopykatConfig", "CopykatResult", "copykat", "copykat_by_batch"]
8
+ __version__ = "0.2.0"
@@ -0,0 +1 @@
1
+ """Pieces of R reproduced with R's arithmetic and random stream."""
@@ -0,0 +1,158 @@
1
+ """R's ``hclust`` (``ward.D`` and ``ward.D2``) and ``cutree``.
2
+
3
+ ``hclust`` follows R's Fortran routine (F. Murtagh's nearest-neighbour list, ``hclust.f``): at
4
+ every step the pair with the smallest nearest-neighbour dissimilarity is merged (the first one on
5
+ ties), the dissimilarities to the new cluster are updated with the Lance-Williams formula in the
6
+ same order of operations, and the nearest neighbours are refreshed as R does, including the fix
7
+ for neighbours to the left of the new cluster. ``ward.D2`` squares the dissimilarities first and
8
+ reports square roots as heights. ``cutree(k)`` undoes the last ``k - 1`` merges and numbers the
9
+ groups in the order in which the observations first appear, as R does.
10
+
11
+ The update of each step is vectorised over the remaining clusters.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ import numpy as np
18
+
19
+ _INF = 1e300
20
+
21
+
22
+ @dataclass
23
+ class HClust:
24
+ """An R ``hclust`` result: ``merge`` (n-1 x 2, R's signed convention), ``height`` and
25
+ ``order`` (1-based, as in R), plus the raw merge pairs (0-based)."""
26
+
27
+ merge: np.ndarray
28
+ height: np.ndarray
29
+ order: np.ndarray
30
+ pairs: np.ndarray
31
+ labels: np.ndarray | None = None
32
+
33
+ @property
34
+ def n(self) -> int:
35
+ return self.pairs.shape[0] + 1
36
+
37
+ def cutree(self, k: int) -> np.ndarray:
38
+ return cutree(self.pairs, k)
39
+
40
+
41
+ def hclust(d: np.ndarray, method: str = "ward.D", labels=None) -> HClust:
42
+ """``hclust(as.dist(d), method)`` for a square symmetric dissimilarity matrix ``d``."""
43
+ if method not in ("ward.D", "ward.D2"):
44
+ raise ValueError("method must be 'ward.D' or 'ward.D2'")
45
+ D = np.array(d, dtype=np.float64, copy=True)
46
+ n = D.shape[0]
47
+ if n < 2:
48
+ raise ValueError("must have n >= 2 objects to cluster")
49
+ if method == "ward.D2":
50
+ D = D * D
51
+ np.fill_diagonal(D, _INF)
52
+ idx = np.arange(n)
53
+ membr = np.ones(n)
54
+ active = np.ones(n, dtype=bool)
55
+ # nearest neighbour to the right of each i (first minimum)
56
+ upper = np.where(idx[None, :] > idx[:, None], D, np.inf)
57
+ nn = np.argmin(upper, axis=1)
58
+ disnn = upper[idx, nn]
59
+ del upper
60
+ nn[n - 1] = -1
61
+ disnn[n - 1] = np.inf
62
+ pairs = np.empty((n - 1, 2), dtype=np.int64)
63
+ crit = np.empty(n - 1)
64
+ for step in range(n - 1):
65
+ cand = np.where(active[: n - 1], disnn[: n - 1], np.inf)
66
+ im = int(np.argmin(cand))
67
+ dmin = cand[im]
68
+ jm = int(nn[im])
69
+ i2, j2 = min(im, jm), max(im, jm)
70
+ pairs[step] = (i2, j2)
71
+ crit[step] = np.sqrt(dmin) if method == "ward.D2" else dmin
72
+ active[j2] = False
73
+ ks = np.flatnonzero(active)
74
+ ks = ks[ks != i2]
75
+ if ks.size:
76
+ d12 = D[i2, j2]
77
+ mk = membr[ks]
78
+ new = ((membr[i2] + mk) * D[i2, ks] + (membr[j2] + mk) * D[j2, ks]) - mk * d12
79
+ new = new / ((membr[i2] + membr[j2]) + mk)
80
+ D[i2, ks] = new
81
+ D[ks, i2] = new
82
+ right = ks > i2
83
+ if right.any():
84
+ r = int(np.argmin(new[right]))
85
+ dnew, jj = new[right][r], int(ks[right][r])
86
+ else:
87
+ dnew, jj = np.inf, -1
88
+ left = ~right
89
+ if left.any():
90
+ kl, vl = ks[left], new[left]
91
+ closer = vl < disnn[kl]
92
+ disnn[kl[closer]] = vl[closer]
93
+ nn[kl[closer]] = i2
94
+ else:
95
+ dnew, jj = np.inf, -1
96
+ membr[i2] += membr[j2]
97
+ disnn[i2] = dnew
98
+ nn[i2] = jj
99
+ rows = np.flatnonzero(active[: n - 1] & ((nn[: n - 1] == i2) | (nn[: n - 1] == j2)))
100
+ if rows.size:
101
+ sub = D[rows]
102
+ ok = active[None, :] & (idx[None, :] > rows[:, None])
103
+ sub = np.where(ok, sub, np.inf)
104
+ jmin = np.argmin(sub, axis=1)
105
+ dm = sub[np.arange(rows.size), jmin]
106
+ none = ~np.isfinite(dm)
107
+ nn[rows] = np.where(none, -1, jmin)
108
+ disnn[rows] = np.where(none, np.inf, dm)
109
+ merge, order = _hcass2(pairs + 1, n)
110
+ return HClust(merge=merge, height=crit, order=order, pairs=pairs,
111
+ labels=None if labels is None else np.asarray(labels))
112
+
113
+
114
+ def _hcass2(pairs1: np.ndarray, n: int) -> tuple[np.ndarray, np.ndarray]:
115
+ """R's ``hcass2``: the signed merge matrix and the leaf order (both 1-based)."""
116
+ ia = pairs1[:, 0].tolist()
117
+ ib = pairs1[:, 1].tolist()
118
+ iia, iib = list(ia), list(ib)
119
+ last = {} # cluster representative -> step that last merged it
120
+ for i in range(n - 1):
121
+ a, b = ia[i], ib[i]
122
+ iia[i] = last.get(a, -a)
123
+ iib[i] = last.get(b, -b)
124
+ last[min(a, b)] = i + 1
125
+ for i in range(n - 1):
126
+ if iia[i] > 0 and iib[i] < 0:
127
+ iia[i], iib[i] = iib[i], iia[i]
128
+ if iia[i] > 0 and iib[i] > 0:
129
+ iia[i], iib[i] = min(iia[i], iib[i]), max(iia[i], iib[i])
130
+ order = [iia[n - 2], iib[n - 2]]
131
+ for i in range(n - 2, 0, -1):
132
+ j = order.index(i)
133
+ order[j:j + 1] = [iia[i - 1], iib[i - 1]]
134
+ return np.column_stack([iia, iib]).astype(np.int64), -np.asarray(order, dtype=np.int64)
135
+
136
+
137
+ def cutree(pairs: np.ndarray, k: int) -> np.ndarray:
138
+ """``cutree(tree, k)`` from the 0-based merge pairs: 1-based group numbers in the order of
139
+ first appearance."""
140
+ n = pairs.shape[0] + 1
141
+ if not 1 <= k <= n:
142
+ raise ValueError(f"k must be between 1 and {n}")
143
+ parent = np.arange(n)
144
+
145
+ def find(x):
146
+ while parent[x] != x:
147
+ parent[x] = parent[parent[x]]
148
+ x = parent[x]
149
+ return x
150
+
151
+ for a, b in pairs[: n - k]:
152
+ ra, rb = find(int(a)), find(int(b))
153
+ parent[rb] = ra
154
+ roots = np.array([find(i) for i in range(n)])
155
+ _, first, inv = np.unique(roots, return_index=True, return_inverse=True)
156
+ rank = np.empty(first.size, dtype=np.int64)
157
+ rank[np.argsort(first)] = np.arange(1, first.size + 1)
158
+ return rank[inv]
@@ -0,0 +1,95 @@
1
+ """mixtools ``normalmixEM`` as CopyKAT calls it: three components with a common standard deviation
2
+ (``arbvar = FALSE``), starting from given weights, means and sigma, run on several vectors at once.
3
+
4
+ Each EM iteration follows mixtools: ``lambda = colMeans(post)``; each mean is
5
+ ``sum(post[, j] * x) / (n * lambda[j])``; the common sigma is
6
+ ``sqrt(sum(post * res) / (n * sum(lambda)))``; the posteriors, squared residuals and
7
+ log-likelihood come from mixtools' ``normpost`` C routine (scaled by the component with the
8
+ smallest standardised residual, the log-likelihood added in order). The loop stops when the
9
+ log-likelihood gain is at most ``epsilon`` or after ``maxit`` iterations.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+
17
+ _LD = np.longdouble
18
+ _HALF_LOG_2PI = 0.91893853320467274178
19
+
20
+
21
+ @dataclass
22
+ class MixFit:
23
+ lam: np.ndarray # (B, 3)
24
+ mu: np.ndarray # (B, 3)
25
+ sigma: np.ndarray # (B,)
26
+ loglik: np.ndarray # (B,)
27
+ iterations: np.ndarray
28
+
29
+
30
+ def _normpost(x, mu, sigma, lam):
31
+ """mixtools ``normpost`` for B vectors at once: posteriors and squared residuals (B, 3, n) and
32
+ the log-likelihood (B,)."""
33
+ B, n = x.shape
34
+ lsr = lam / sigma[:, None] # LamSigRatio (B, 3)
35
+ llsr = np.log(lsr)
36
+ res2 = x[:, None, :] - mu[:, :, None]
37
+ res2 *= res2
38
+ den = (2.0 * sigma) * sigma
39
+ work = res2 / den[:, None, None]
40
+ w0, w1, w2 = work[:, 0], work[:, 1], work[:, 2]
41
+ # first component with the smallest standardised residual (j == 0 || r < min)
42
+ lt1 = w1 < w0
43
+ mn = np.where(lt1, w1, w0)
44
+ lt2 = w2 < mn
45
+ mn = np.where(lt2, w2, mn)
46
+ minj = np.where(lt2, 2, np.where(lt1, 1, 0))
47
+ rb = np.arange(B)[:, None]
48
+ lsr_min = lsr[rb, minj]
49
+ post = np.empty_like(work)
50
+ rowsum = np.ones((B, n))
51
+ for j in range(3):
52
+ wj = (lsr[:, j][:, None] / lsr_min) * np.exp(mn - work[:, j])
53
+ is_min = minj == j
54
+ wj[is_min] = 1.0
55
+ post[:, j] = wj
56
+ rowsum += np.where(is_min, 0.0, wj)
57
+ post /= rowsum[:, None, :]
58
+ terms = (np.log(rowsum) - mn) + llsr[rb, minj]
59
+ terms[:, 0] += -float(n) * _HALF_LOG_2PI
60
+ ll = np.cumsum(terms, axis=1)[:, -1]
61
+ return post, res2, ll
62
+
63
+
64
+ def normalmix3(x: np.ndarray, sigma0: np.ndarray, mu0=(-0.2, 0.0, 0.2), lam0=None,
65
+ epsilon: float = 1e-8, maxit: int = 1000) -> MixFit:
66
+ """``normalmixEM(x[b], lambda = rep(1, 3)/3, mu = mu0, sigma = sigma0[b], arbvar = FALSE,
67
+ ECM = FALSE, maxit = maxit)`` for each row ``x[b]``."""
68
+ x = np.atleast_2d(np.asarray(x, dtype=np.float64))
69
+ B, n = x.shape
70
+ lam_init = np.full(3, 1.0) / 3.0 if lam0 is None else np.asarray(lam0, dtype=np.float64)
71
+ lam_init = lam_init / lam_init.sum()
72
+ lam = np.tile(lam_init, (B, 1))
73
+ mu = np.tile(np.asarray(mu0, dtype=np.float64), (B, 1))
74
+ sigma = np.asarray(sigma0, dtype=np.float64).reshape(B).copy()
75
+ post, res, ll = _normpost(x, mu, sigma, lam)
76
+ iters = np.zeros(B, dtype=np.int64)
77
+ act = np.ones(B, dtype=bool)
78
+ while act.any():
79
+ a = np.flatnonzero(act)
80
+ xa, pa, ra = x[a], post[a], res[a]
81
+ lam_a = (pa.sum(axis=2, dtype=_LD) / n).astype(np.float64)
82
+ mu_a = (pa * xa[:, None, :]).sum(axis=2, dtype=_LD).astype(np.float64) / (float(n) * lam_a)
83
+ num = (pa * ra).reshape(a.size, 3 * n).sum(axis=1, dtype=_LD)
84
+ sl = lam_a.astype(_LD)
85
+ sl = (sl[:, 0] + sl[:, 1]) + sl[:, 2]
86
+ sig_a = np.sqrt(num.astype(np.float64) / (float(n) * sl.astype(np.float64)))
87
+ if np.any(sig_a < 1e-8):
88
+ raise FloatingPointError("normalmixEM: a variance went to zero (R restarts at random)")
89
+ p2, r2, ll2 = _normpost(xa, mu_a, sig_a, lam_a)
90
+ diff = ll2 - ll[a]
91
+ lam[a], mu[a], sigma[a] = lam_a, mu_a, sig_a
92
+ post[a], res[a], ll[a] = p2, r2, ll2
93
+ iters[a] += 1
94
+ act[a] = (diff > epsilon) & (iters[a] < maxit)
95
+ return MixFit(lam=lam, mu=mu, sigma=sigma, loglik=ll, iterations=iters)
@@ -0,0 +1,112 @@
1
+ """Pieces of base R with R's arithmetic.
2
+
3
+ ``mean`` adds in long double and corrects with a second pass (summary.c); ``var``/``sd`` use that
4
+ mean and a long-double sum of squares (cov.c); ``median`` averages the two middle values for even
5
+ lengths; ``qnorm`` is R's ``qnorm5`` (Wichura's AS 241).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ _LD = np.longdouble
12
+
13
+
14
+ def _seq_sum(m: np.ndarray, axis: int) -> np.ndarray:
15
+ """Long-double sum along ``axis``, adding the slices in order (R's LDOUBLE loops)."""
16
+ m = np.moveaxis(np.asarray(m), axis, 0)
17
+ s = np.zeros(m.shape[1:], dtype=_LD)
18
+ for i in range(m.shape[0]):
19
+ s += m[i]
20
+ return s
21
+
22
+
23
+ def r_means(m: np.ndarray, axis: int = 0) -> np.ndarray:
24
+ """``apply(m, axis + 1 == 2 ? 2 : 1, mean)``: R ``mean`` of each slice along ``axis``."""
25
+ m = np.asarray(m, dtype=np.float64)
26
+ n = m.shape[axis]
27
+ s = _seq_sum(m, axis) / n
28
+ ok = np.isfinite(s.astype(np.float64))
29
+ if ok.any():
30
+ d = np.moveaxis(m, axis, 0).astype(_LD) - s
31
+ t = np.zeros_like(s)
32
+ for i in range(d.shape[0]):
33
+ t += d[i]
34
+ s = np.where(ok, s + t / n, s)
35
+ return s.astype(np.float64)
36
+
37
+
38
+ def r_mean(x) -> float:
39
+ """``mean(x)`` for a double vector."""
40
+ return float(r_means(np.asarray(x, dtype=np.float64).reshape(-1, 1), 0)[0])
41
+
42
+
43
+ def r_sds(m: np.ndarray, axis: int = 0) -> np.ndarray:
44
+ """``sd`` of each slice along ``axis`` (cov.c: long-double mean, then the squares added in
45
+ long double and divided by n - 1)."""
46
+ m = np.asarray(m, dtype=np.float64)
47
+ n = m.shape[axis]
48
+ mu = r_means(m, axis)
49
+ d = np.moveaxis(m, axis, 0) - mu
50
+ ss = _seq_sum(d * d, 0)
51
+ return np.sqrt((ss / (n - 1)).astype(np.float64))
52
+
53
+
54
+ def r_sd(x) -> float:
55
+ """``sd(x)``."""
56
+ return float(r_sds(np.asarray(x, dtype=np.float64).reshape(-1, 1), 0)[0])
57
+
58
+
59
+ def r_medians(m: np.ndarray, axis: int = 0) -> np.ndarray:
60
+ """``median`` of each slice along ``axis`` (the mean of the two middle values for even n)."""
61
+ return np.median(np.asarray(m, dtype=np.float64), axis=axis)
62
+
63
+
64
+ def qnorm(p, log=np.log) -> np.ndarray:
65
+ """``qnorm(p)`` (lower tail): R's ``qnorm5``. ``p`` must lie in (0, 1).
66
+
67
+ ``log`` evaluates the tails (|p - 0.5| > 0.425); NumPy's ``log`` can differ from the C library's
68
+ in the last bit."""
69
+ p = np.asarray(p, dtype=np.float64)
70
+ q = p - 0.5
71
+ val = np.empty_like(p)
72
+ c = np.abs(q) <= 0.425
73
+ if c.any():
74
+ qq = q[c]
75
+ r = 0.180625 - qq * qq
76
+ val[c] = (qq * (((((((r * 2509.0809287301226727 + 33430.575583588128105) * r
77
+ + 67265.770927008700853) * r + 45921.953931549871457) * r
78
+ + 13731.693765509461125) * r + 1971.5909503065514427) * r
79
+ + 133.14166789178437745) * r + 3.387132872796366608)
80
+ / (((((((r * 5226.495278852854561 + 28729.085735721942674) * r
81
+ + 39307.89580009271061) * r + 21213.794301586595867) * r
82
+ + 5394.1960214247511077) * r + 687.1870074920579083) * r
83
+ + 42.313330701600911252) * r + 1.0))
84
+ t = ~c
85
+ if t.any():
86
+ qt, pt = q[t], p[t]
87
+ r = np.sqrt(-log(np.where(qt > 0, 1.0 - pt, pt)))
88
+ out = np.empty_like(r)
89
+ a = r <= 5.0
90
+ if a.any():
91
+ ra = r[a] - 1.6
92
+ out[a] = ((((((((ra * 7.7454501427834140764e-4 + 0.0227238449892691845833) * ra
93
+ + 0.24178072517745061177) * ra + 1.27045825245236838258) * ra
94
+ + 3.64784832476320460504) * ra + 5.7694972214606914055) * ra
95
+ + 4.6303378461565452959) * ra + 1.42343711074968357734)
96
+ / (((((((ra * 1.05075007164441684324e-9 + 5.475938084995344946e-4) * ra
97
+ + 0.0151986665636164571966) * ra + 0.14810397642748007459) * ra
98
+ + 0.68976733498510000455) * ra + 1.6763848301838038494) * ra
99
+ + 2.05319162663775882187) * ra + 1.0))
100
+ b = ~a
101
+ if b.any():
102
+ rb = r[b] - 5.0
103
+ out[b] = ((((((((rb * 2.01033439929228813265e-7 + 2.71155556874348757815e-5) * rb
104
+ + 0.0012426609473880784386) * rb + 0.026532189526576123093) * rb
105
+ + 0.29656057182850489123) * rb + 1.7848265399172913358) * rb
106
+ + 5.4637849111641143699) * rb + 6.6579046435011037772)
107
+ / (((((((rb * 2.04426310338993978564e-15 + 1.4215117583164458887e-7) * rb
108
+ + 1.8463183175100546818e-5) * rb + 7.868691311456132591e-4) * rb
109
+ + 0.0148753612908506148525) * rb + 0.13692988092273580531) * rb
110
+ + 0.59983220655588793769) * rb + 1.0))
111
+ val[t] = np.where(qt < 0.0, -out, out)
112
+ return val