Saving a strategy and applying it to a batch (Gating-ML 2.0)#
The reason to model a gating strategy as an object that does not belong to any one sample: one strategy, ninety files. This notebook saves a strategy, reads it back, and runs it across a batch — and then does the thing that actually matters, which is comparing the resulting frequencies across samples and noticing where the strategy stopped fitting.
Continues from gating.
# No data of your own is needed. ov.datasets.flow_demo_fcs() writes a SIMULATED
# FCS 3.1 file into the current working directory: 59,600 events, nine known
# populations, a real $SPILLOVER keyword, and a detector noise floor that pushes
# dim events below zero — which is why a log display axis will not do.
import numpy as np, pathlib
import omicverse as ov
ov.style() # omicverse's plotting defaults — call this once, up front
path = ov.datasets.flow_demo_fcs()
print(path)
🔬 Starting plot initialization...
🧬 Detecting GPU devices…
✅ Apple Silicon MPS detected
• [MPS] Apple Silicon GPU - Metal Performance Shaders available
____ _ _ __
/ __ \____ ___ (_)___| | / /__ _____________
/ / / / __ `__ \/ / ___/ | / / _ \/ ___/ ___/ _ \
/ /_/ / / / / / / / /__ | |/ / __/ / (__ ) __/
\____/_/ /_/ /_/_/\___/ |___/\___/_/ /____/\___/
🔖 Version: 2.2.4 📚 Tutorials: https://omicverse.readthedocs.io/
✅ plot_set complete.
flow_demo.fcs
import omicverse as ov
import matplotlib.pyplot as plt
import numpy as np
adata = ov.datasets.flow_demo() # = read_fcs(flow_demo_fcs()) + obs['population']
ov.flow.compensate(adata)
lin = ov.flow.Linear(t=262144.0)
lg = ov.flow.Logicle(t=262144.0, m=4.5, w=1.0, a=0.0)
adata
AnnData object with n_obs × n_vars = 59600 × 8
obs: 'sample', 'population'
var: 'n', 'channel', 'marker', 'PnB', 'PnE', 'PnG', 'PnR'
uns: 'meta', 'fcs', 'flow_demo', 'flow'
layers: 'uncompensated'
def at(v):
"""A data value expressed on the logicle scale — how a threshold is really
written. Typing 0.42 instead of at(600) is how gates drift."""
return float(lg.apply(np.array([float(v)]))[0])
cells = ov.flow.PolygonGate(
name="Cells", dims=("FSC-A", "SSC-A"),
transforms={"FSC-A": lin, "SSC-A": lin},
vertices=np.array([[0.12,0.01],[0.12,0.17],[0.40,0.32],
[0.68,0.32],[0.68,0.09],[0.38,0.01]]))
singlets = ov.flow.PolygonGate(
name="Singlets", dims=("FSC-A", "FSC-H"),
transforms={"FSC-A": lin, "FSC-H": lin},
vertices=np.array([[0.05,0.03],[0.05,0.10],[0.75,0.86],[0.75,0.69]]))
live = ov.flow.RectangleGate(
name="Live", dims=("Viability",), transforms={"Viability": lg},
bounds=((None, at(1200)),))
cd3 = ov.flow.RectangleGate(
name="CD3+", dims=("CD3",), transforms={"CD3": lg},
bounds=((at(600), None),))
quad = ov.flow.QuadrantGate(
name="CD4/CD8", dims=("CD4", "CD8"), transforms={"CD4": lg, "CD8": lg},
dividers=(at(600), at(600)),
quadrant_names=("DN", "CD4+CD8-", "CD4-CD8+", "DP"))
gs = ov.flow.GatingStrategy("T cell panel")
gs.add_gate(cells)
gs.add_gate(singlets, parent="Cells")
gs.add_gate(live, parent="Singlets")
gs.add_gate(cd3, parent="Live")
gs.add_gate(quad, parent="CD3+")
print(gs.tree())
root
└ Cells
└ Singlets
└ Live
└ CD3+
└ CD4/CD8
└ DN
└ CD4+CD8-
└ CD4-CD8+
└ DP
Round-tripping through a dict#
to_dict is JSON-able and is the seam a gate editor works against — a UI renders
and edits these dicts and never needs the Python objects.
import json
d = gs.to_dict()
print(json.dumps(d["gates"][3], indent=2)[:520], "...")
{
"kind": "rectangle",
"name": "CD3+",
"dims": [
"CD3"
],
"transforms": {
"CD3": {
"type": "logicle",
"t": 262144.0,
"w": 1.0,
"m": 4.5,
"a": 0.0
}
},
"bounds": [
[
0.34689636224578013,
null
]
],
"parent": "Live"
} ...
restored = ov.flow.GatingStrategy.from_dict(d)
print(restored)
print(restored.tree())
GatingStrategy('T cell panel', 5 gates)
root
└ Cells
└ Singlets
└ Live
└ CD3+
└ CD4/CD8
└ DN
└ CD4+CD8-
└ CD4-CD8+
└ DP
The round trip has to preserve the transforms, not just the geometry, or a restored gate lands somewhere else entirely. Check it rather than assume it:
a = gs.apply(adata.copy())
b = restored.apply(adata.copy())
identical = all(np.array_equal(a.masks[k], b.masks[k]) for k in a.masks)
print(f"populations: {sorted(a.masks) == sorted(b.masks)}")
print(f"every mask identical after the round trip: {identical}")
populations: True
every mask identical after the round trip: True
Gating-ML 2.0#
The dict is convenient but omicverse-specific. Gating-ML 2.0 is the ISAC interchange standard, and it is what FlowJo, Cytobank and flowCore read.
ov.flow.write_gatingml(gs, "strategy.xml")
xml = pathlib.Path("strategy.xml").read_text()
print(f"{len(xml):,} bytes")
print(xml[:700], "...")
4,757 bytes
<?xml version='1.0' encoding='utf-8'?>
<ns0:Gating-ML xmlns:ns0="http://www.isac-net.org/std/Gating-ML/v2.0/gating" xmlns:ns1="http://www.isac-net.org/std/Gating-ML/v2.0/transformations" xmlns:ns2="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes" xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating" xmlns:transforms="http://www.isac-net.org/std/Gating-ML/v2.0/transformations" xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
<ns1:transformation ns1:id="xf_FSC_negA_linear">
<ns1:flin ns1:T="262144.0" ns1:A="0.0" />
</ns1:transformation>
<ns1:transformation ns1:id="xf_SSC_negA_linear">
<ns1:flin ns1:T="262144.0" ns1:A="0.0" />
</ns1:transfo ...
from_file = ov.flow.read_gatingml("strategy.xml")
c = from_file.apply(adata.copy())
print(from_file)
print("masks identical to the original:",
all(np.array_equal(a.masks[k], c.masks[k]) for k in a.masks))
GatingStrategy('strategy', 5 gates)
masks identical to the original: True
One strategy, several samples#
Now the point of all of it. Three files with different biology — the second has lost most of its CD4 compartment, the third is a lower-quality run — gated by the same strategy object.
All three are synthetic: they are re-syntheses of the same simulated panel with a population thinned, not real patients. The frequencies below are therefore a demonstration of the mechanism and nothing else.
def variant(seed, thin=None):
"""A second sample: re-simulate with a different seed, optionally thinning
a population to mimic a different patient."""
ad = ov.datasets.flow_demo(seed=seed)
ov.flow.compensate(ad)
if thin:
rng = np.random.default_rng(seed + 100)
keep = np.ones(ad.n_obs, bool)
for pop, drop in thin.items():
idx = np.flatnonzero((ad.obs["population"] == pop).to_numpy())
keep[rng.choice(idx, int(len(idx) * drop), replace=False)] = False
ad = ad[keep].copy()
return ad
batch = {
"healthy": adata,
"CD4-depleted": variant(1, thin={"CD4 T": 0.85}),
"smaller run": variant(2, thin={"CD4 T": 0.2, "CD8 T": 0.2}),
}
{k: v.n_obs for k, v in batch.items()}
{'healthy': 59600, 'CD4-depleted': 45150, 'smaller run': 54200}
import pandas as pd
rows = []
for name, sample in batch.items():
r = gs.apply(sample.copy())
s = r.stats().set_index("population")["freq_parent"]
rows.append(s.rename(name))
freqs = pd.concat(rows, axis=1)
freqs.style.format("{:.1%}")
| healthy | CD4-depleted | smaller run | |
|---|---|---|---|
| population | |||
| CD3+ | 69.5% | 53.3% | 65.0% |
| CD4+CD8- | 58.8% | 17.7% | 57.9% |
| CD4-CD8+ | 34.6% | 69.2% | 34.0% |
| CD4/CD8 | 100.0% | 100.0% | 100.0% |
| Cells | 81.5% | 75.6% | 79.7% |
| DN | 4.8% | 9.7% | 6.0% |
| DP | 1.7% | 3.5% | 2.1% |
| Live | 92.8% | 89.4% | 91.8% |
| Singlets | 92.2% | 88.9% | 91.2% |
Easier to read as a picture — and this is the figure a batch actually produces, one bar group per population, one colour per sample:
fig, ax = plt.subplots(figsize=(8.6, 3.2))
order = ["Cells", "Singlets", "Live", "CD3+", "CD4+CD8-", "CD4-CD8+", "DN", "DP"]
sub = freqs.loc[order]
x, w = np.arange(len(order)), 0.26
for i, col in enumerate(sub.columns):
ax.bar(x + (i - 1) * w, sub[col], w, label=col)
ax.set_xticks(x, labels=order, rotation=30, ha="right")
ax.set_ylabel("frequency of parent")
ax.set_title("one strategy, three samples", fontsize=9)
ax.legend(fontsize=8, frameon=False)
plt.tight_layout()
The CD4 depletion is unmissable in CD4+CD8- — and note that
freq_parent is what makes the comparison legitimate, since the three files do
not have the same number of events.
Side by side, on the same axes, with the same gate:
Diffing two strategies#
Because a strategy is data, two of them can be compared — which is the other thing that is hard to do by hand. Here a colleague has tightened the viability threshold, three nodes above the populations anyone will report:
theirs = ov.flow.GatingStrategy.from_dict(gs.to_dict())
theirs.name = "T cell panel (revised)"
theirs.gates["Live"].bounds = ((None, at(300)),)
mine = gs.apply(adata.copy()).stats().set_index("population")["count"]
yours = theirs.apply(adata.copy()).stats().set_index("population")["count"]
diff = pd.DataFrame({"original": mine, "revised": yours})
diff["delta"] = diff["revised"] - diff["original"]
diff["pct"] = (diff["delta"] / diff["original"]).map("{:+.1%}".format)
diff
original revised delta pct
population
CD3+ 28895 27954 -941 -3.3%
CD4+CD8- 16999 16453 -546 -3.2%
CD4-CD8+ 9998 9674 -324 -3.2%
CD4/CD8 28895 27954 -941 -3.3%
Cells 48587 48587 0 +0.0%
DN 1398 1343 -55 -3.9%
DP 500 484 -16 -3.2%
Live 41574 40085 -1489 -3.6%
Singlets 44785 44785 0 +0.0%
One threshold, moved at a node nobody reports, and every population below it changes. This is the argument for keeping the strategy as an object that can be diffed rather than as a sequence of clicks: the number that moved is not the one that was edited.
What is next#
Everything so far is manual and auditable. The last notebook adds unsupervised clustering — and is explicit about what it does not replace.