Skip to content

Commit 9d42a9a

Browse files
Update
1 parent f0897ae commit 9d42a9a

687 files changed

Lines changed: 91063 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎engine/spectral.py‎

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
"""Spectral unmixing maths for StreamFLOW. Pure NumPy/SciPy/scikit-learn - no engine state.
2+
3+
Clean-room from published methods only (StreamFLOW is Apache-2.0; nothing here is adapted from
4+
the LGPL Honeychrome checkout):
5+
6+
* Ordinary / weighted least squares unmixing - Novo, Gregori & Rajwa, Cytometry A 2013:
7+
X = E . Wt, W = (M Omega^-1 M^T)^-1 M Omega^-1
8+
with M the (fluorophores x detectors) signature matrix and Omega = diag(per-detector mean),
9+
the Poisson-noise weighting. OLS is the Omega = I special case.
10+
* Per-cell autofluorescence - an unstained-derived library of AF spectra; each event is unmixed
11+
against every candidate AF signature and keeps the one with the lowest residual.
12+
* Signature QC - cosine similarity matrix and a collinearity index from the inverse Gram matrix.
13+
14+
Conventions: events are (N x D) raw detector values; signatures are rows, each scaled to peak 1.
15+
Anything feeding a reported number is seeded (see `seed` arguments), per the engine invariant.
16+
"""
17+
import numpy as np
18+
19+
_EPS = 1e-12
20+
21+
22+
def _otsu(v, bins=256):
23+
"""Otsu threshold of a 1-D array, or None when the data are (near) constant."""
24+
v = np.asarray(v, dtype=float)
25+
v = v[np.isfinite(v)]
26+
if v.size < 2 or np.ptp(v) <= 0:
27+
return None
28+
hist, edges = np.histogram(v, bins=bins)
29+
p = hist / hist.sum()
30+
centres = (edges[:-1] + edges[1:]) / 2.0
31+
w0 = np.cumsum(p)
32+
m = np.cumsum(p * centres)
33+
mt = m[-1]
34+
denom = w0 * (1.0 - w0)
35+
denom[denom <= 0] = np.nan
36+
between = (mt * w0 - m) ** 2 / denom
37+
if np.all(np.isnan(between)):
38+
return None
39+
return float(centres[int(np.nanargmax(between))])
40+
41+
42+
def saturation_mask(events, ceiling=None, frac=0.999):
43+
"""True for events NOT pinned at the top of the detector range in any detector.
44+
45+
Pinned events bias positive medians low (the true signal is clipped) and distort every
46+
signature that includes them, so they are dropped before signatures are built. `ceiling` is
47+
the per-detector range maximum (e.g. $PnR); when omitted the observed column maximum is used.
48+
"""
49+
e = np.asarray(events, dtype=float)
50+
top = np.max(e, axis=0) if ceiling is None else np.asarray(ceiling, dtype=float)
51+
return ~np.any(e >= frac * top, axis=1)
52+
53+
54+
def peak_detector(events):
55+
"""Index of the detector that best characterises a single-stain control.
56+
57+
First principal component of the (uncentred) events, argmax of its absolute loading - robust
58+
to a dim control whose brightest *median* detector is dominated by autofluorescence.
59+
"""
60+
e = np.asarray(events, dtype=float)
61+
if e.shape[0] < 2:
62+
return int(np.argmax(np.median(e, axis=0)))
63+
# uncentred covariance: the dominant direction is the control's spectrum itself
64+
_, _, vt = np.linalg.svd(e - np.median(e, axis=0), full_matrices=False)
65+
return int(np.argmax(np.abs(vt[0])))
66+
67+
68+
def build_signature(events, negative=None, min_pos=50, saturation=None):
69+
"""Signature of one single-stain control.
70+
71+
events: (N x D) raw values of the control.
72+
negative: optional (D,) autofluorescence baseline (e.g. unstained median) subtracted from the
73+
positive median; when omitted the control's own negative population is used.
74+
Returns {signature (D,), peak, n_pos, n_neg, threshold, ok, reason}. `signature` is scaled so
75+
its peak detector is 1.0.
76+
"""
77+
e = np.asarray(events, dtype=float)
78+
out = {"ok": False, "reason": "", "n_pos": 0, "n_neg": 0, "peak": None,
79+
"threshold": None, "signature": None, "n_saturated": 0}
80+
if e.ndim != 2 or e.shape[0] < min_pos * 2:
81+
out["reason"] = "too few events (%d)" % (e.shape[0] if e.ndim == 2 else 0)
82+
return out
83+
if saturation is not None:
84+
keep = saturation_mask(e, saturation)
85+
out["n_saturated"] = int((~keep).sum())
86+
if keep.sum() >= min_pos * 2:
87+
e = e[keep]
88+
pk = peak_detector(e)
89+
out["peak"] = pk
90+
thr = _otsu(np.arcsinh(e[:, pk] / 150.0))
91+
if thr is None:
92+
out["reason"] = "no bimodal split on the peak detector"
93+
return out
94+
thr_raw = float(np.sinh(thr) * 150.0)
95+
out["threshold"] = thr_raw
96+
pos = e[:, pk] > thr_raw
97+
out["n_pos"], out["n_neg"] = int(pos.sum()), int((~pos).sum())
98+
if out["n_pos"] < min_pos:
99+
out["reason"] = "only %d positive events" % out["n_pos"]
100+
return out
101+
base = np.median(e[~pos], axis=0) if negative is None else np.asarray(negative, dtype=float)
102+
if negative is None and out["n_neg"] < min_pos:
103+
base = np.zeros(e.shape[1])
104+
sig = np.median(e[pos], axis=0) - base
105+
sig = np.clip(sig, 0.0, None) # a spectrum has no negative lobes; noise only
106+
peak_val = sig[pk]
107+
if peak_val <= 0:
108+
out["reason"] = "positive population is not brighter than the negative"
109+
return out
110+
out["signature"] = sig / peak_val
111+
out["ok"] = True
112+
return out
113+
114+
115+
def _span_residual(rows, v, rel_tol=1e-9):
116+
"""Norm of `v` after removing its projection onto span(rows), by modified Gram-Schmidt with
117+
re-orthogonalisation. Rank-tolerant: a row that is (numerically) in the span of earlier rows adds
118+
nothing to the basis, so a *dependent* set is handled instead of silently producing garbage."""
119+
basis = []
120+
for r in rows:
121+
w = np.array(r, dtype=float)
122+
n0 = np.linalg.norm(w)
123+
for _ in range(2):
124+
for q in basis:
125+
w -= (q @ w) * q
126+
n1 = np.linalg.norm(w)
127+
if n0 > 0 and n1 > rel_tol * n0:
128+
basis.append(w / n1)
129+
w = np.array(v, dtype=float)
130+
for _ in range(2):
131+
for q in basis:
132+
w -= (q @ w) * q
133+
return float(np.linalg.norm(w))
134+
135+
136+
def signature_qc(M, rel_tol=1e-9):
137+
"""Per-pair cosine similarity and a per-fluorophore collinearity index.
138+
139+
M is (F x D). The collinearity index of row i is ||m_i|| / dist(m_i, span of the other rows),
140+
which equals sqrt((G^-1)_ii) * ||m_i|| for the Gram matrix G = M M^T but is computed without
141+
inverting G. It is 1.0 for an orthogonal set and grows without bound as a fluorophore becomes
142+
explainable by the others (unmixing then amplifies noise by that factor); it is `inf` when the
143+
fluorophore lies in the span of the others to within `rel_tol` - a dependent set cannot be unmixed.
144+
(Earlier this inverted G directly: for a dependent set numpy returned rounding garbage and the
145+
clip to >= 0 turned it into 0.0, i.e. "not collinear" - the opposite of the truth.)
146+
"""
147+
M = np.asarray(M, dtype=float)
148+
norms = np.linalg.norm(M, axis=1, keepdims=True)
149+
unit = M / np.maximum(norms, _EPS)
150+
cos = unit @ unit.T
151+
coll = np.empty(M.shape[0])
152+
for i in range(M.shape[0]):
153+
others = np.delete(M, i, axis=0)
154+
res = _span_residual(others, M[i], rel_tol)
155+
coll[i] = np.inf if res <= rel_tol * norms[i, 0] or norms[i, 0] == 0 else norms[i, 0] / res
156+
return {"cosine": cos, "collinearity": coll,
157+
"condition": float(np.linalg.cond(M)) if M.size else float("nan")}
158+
159+
160+
def unmixing_matrix(M, weights=None):
161+
"""W (F x D) such that abundances = events @ W.T.
162+
163+
weights None -> OLS. weights (D,) -> WLS with Omega = diag(weights), i.e.
164+
W = (M Omega^-1 M^T)^-1 M Omega^-1. Weights are floored so a silent detector cannot dominate.
165+
"""
166+
M = np.asarray(M, dtype=float)
167+
if weights is None:
168+
gram = M @ M.T
169+
return np.linalg.solve(gram, M)
170+
w = np.maximum(np.asarray(weights, dtype=float), _EPS)
171+
inv_w = 1.0 / w
172+
mw = M * inv_w[None, :] # M Omega^-1
173+
return np.linalg.solve(mw @ M.T, mw)
174+
175+
176+
def poisson_weights(events, floor=1.0):
177+
"""Per-detector mean signal of the events: the Poisson variance estimate used as Omega."""
178+
e = np.asarray(events, dtype=float)
179+
return np.maximum(np.mean(np.clip(e, 0, None), axis=0), floor)
180+
181+
182+
def unmix(events, M, method="wls", weights=None):
183+
"""Unmix (N x D) events against M (F x D). Returns (abundances (N x F), W (F x D))."""
184+
e = np.asarray(events, dtype=float)
185+
m = str(method).lower()
186+
if m == "ols":
187+
W = unmixing_matrix(M, None)
188+
elif m == "wls":
189+
W = unmixing_matrix(M, poisson_weights(e) if weights is None else weights)
190+
else:
191+
raise ValueError("Unknown unmixing method %r (use 'ols' or 'wls')." % method)
192+
return e @ W.T, W
193+
194+
195+
def extract_af_library(unstained, k=4, seed=0, min_events=200):
196+
"""Cluster an unstained sample's spectra into up to `k` autofluorescence signatures.
197+
198+
Spectra are normalised to unit L2 norm first so clusters group by *shape* (which is what
199+
unmixing sees), not by brightness. Returns (library (K x D) peak-scaled, counts (K,)).
200+
Seeded: identical input and seed give identical output.
201+
"""
202+
from sklearn.cluster import KMeans
203+
e = np.clip(np.asarray(unstained, dtype=float), 0, None)
204+
norms = np.linalg.norm(e, axis=1)
205+
e = e[norms > 0]
206+
if e.shape[0] < min_events:
207+
raise ValueError("Unstained sample has only %d usable events (need >= %d) for an "
208+
"autofluorescence library." % (e.shape[0], min_events))
209+
unit = e / np.linalg.norm(e, axis=1, keepdims=True)
210+
k = int(max(1, min(k, e.shape[0] // 50)))
211+
km = KMeans(n_clusters=k, n_init=10, random_state=int(seed)).fit(unit)
212+
lib, counts = [], []
213+
for c in range(k):
214+
grp = e[km.labels_ == c]
215+
sig = np.median(grp, axis=0)
216+
lib.append(sig / max(sig.max(), _EPS))
217+
counts.append(int(grp.shape[0]))
218+
order = np.argsort(counts)[::-1]
219+
return np.asarray(lib)[order], np.asarray(counts)[order]
220+
221+
222+
def unmix_with_af(events, M, af_library, method="wls", weights=None, chunk=50000):
223+
"""Per-cell autofluorescence: unmix each event against M plus every candidate AF signature
224+
and keep the candidate with the lowest residual.
225+
226+
Returns (abundances (N x (F+1)) - last column is the AF abundance, af_index (N,), residual (N,)).
227+
"""
228+
e = np.asarray(events, dtype=float)
229+
lib = np.asarray(af_library, dtype=float)
230+
w = poisson_weights(e) if (weights is None and str(method).lower() == "wls") else weights
231+
n, f = e.shape[0], M.shape[0]
232+
out = np.zeros((n, f + 1))
233+
best_idx = np.zeros(n, dtype=np.int32)
234+
best_res = np.full(n, np.inf)
235+
for k in range(lib.shape[0]):
236+
Mk = np.vstack([M, lib[k]])
237+
Wk = unmixing_matrix(Mk, None if str(method).lower() == "ols" else w)
238+
for s in range(0, n, chunk):
239+
blk = e[s:s + chunk]
240+
ab = blk @ Wk.T
241+
res = np.sum((blk - ab @ Mk) ** 2, axis=1)
242+
better = res < best_res[s:s + chunk]
243+
sl = slice(s, s + blk.shape[0])
244+
out[sl][better] = ab[better]
245+
best_idx[sl][better] = k
246+
best_res[sl][better] = res[better]
247+
return out, best_idx, best_res
248+
249+
250+
def residuals(events, M, abundances):
251+
"""Per-event root-sum-square residual: how much of each event the model failed to explain."""
252+
e = np.asarray(events, dtype=float)
253+
return np.sqrt(np.sum((e - np.asarray(abundances) @ np.asarray(M)) ** 2, axis=1))
254+
255+
256+
def write_fcs_f32(path, names, data):
257+
"""Write (N x C) float32 data as a spec-conformant FCS 3.1 LISTMODE file (little-endian):
258+
8-character header offset fields and a closing delimiter after the last TEXT value."""
259+
data = np.asarray(data, dtype="<f4")
260+
n_events, n_chan = data.shape
261+
d = b"/"
262+
for nm in names:
263+
if "/" in nm:
264+
raise ValueError("Channel name %r contains the FCS delimiter '/'." % nm)
265+
266+
def kv(key, val):
267+
return key.encode("ascii") + d + str(val).encode("ascii", "replace") + d
268+
269+
pairs = [kv("$BYTEORD", "1,2,3,4"), kv("$DATATYPE", "F"), kv("$MODE", "L"),
270+
kv("$NEXTDATA", 0), kv("$PAR", n_chan), kv("$TOT", n_events)]
271+
for p, ch in enumerate(names, 1):
272+
pairs += [kv("$P%dB" % p, 32), kv("$P%dN" % p, ch), kv("$P%dE" % p, "0,0"),
273+
kv("$P%dR" % p, 262144)]
274+
body = b"".join(pairs)
275+
data_bytes = n_events * n_chan * 4
276+
t_start = 256 # TEXT begins after the 58-byte header block
277+
d_start = d_end = 0
278+
for _ in range(3): # offsets are self-referential; iterate to a fixed point
279+
text = d + body + kv("$BEGINDATA", d_start) + kv("$ENDDATA", d_end)
280+
t_end = t_start + len(text) - 1
281+
d_start, d_end = t_end + 1, t_end + data_bytes
282+
fits = d_end <= 99999999
283+
hdr = "FCS3.1 %8d%8d%8d%8d%8d%8d" % (t_start, t_end, d_start if fits else 0,
284+
d_end if fits else 0, 0, 0)
285+
with open(path, "wb") as f:
286+
f.write(hdr.encode("ascii").ljust(t_start))
287+
f.write(text)
288+
f.write(data.tobytes())

0 commit comments

Comments
 (0)