| name | bio-applied-flow-cytometry |
| description | Read FCS 2.0/3.0/3.1 files with FlowKit/flowio, apply spillover compensation, logicle/arcsinh transforms, build gating hierarchies, and compute population statistics. Use when analyzing flow cytometry data, .fcs files, panels, compensation matrices, or gating trees. |
| tool_type | python |
| primary_tool | FlowKit |
Flow Cytometry Analysis
When to Use
- Loading raw
.fcs files and inspecting channels, keywords, or spillover metadata before analysis
- Compensating fluorescence spillover and choosing a display transform (logicle vs arcsinh vs biexponential)
- Building a manual gating hierarchy (e.g. Time -> Singlets -> Live -> Lymphocytes -> CD4+/CD8+) and exporting/replicating it via GatingML or a FlowJo
.wsp
- Computing per-gate population statistics (% parent, % total, event counts) across a batch of samples for a panel
- Deciding between FlowKit (full gating engine), flowio (low-level FCS I/O only), and FlowCytometryTools (older pandas-based scripting)
Version Compatibility
- FlowKit >=1.3.2 (Python >=3.9), bundles
flowutils for compensation/transform C extensions
- flowio >=1.3 (low-level FCS 2.0/3.0/3.1 parser/writer; FlowKit's
Sample is built on it)
- FlowCytometryTools ~0.5.1 (pandas-based, no active gating-hierarchy engine; fine for quick one-off scripts)
- CytoML / GatingML (R/Bioconductor
flowCore/openCyto ecosystem) for exchanging gating strategies with FlowJo/Cytobank as GatingML 2.0 XML
Prerequisites
pip install flowkit flowio
- FCS files with a spillover matrix in the
$SPILL/SPILLOVER keyword, or an external compensation CSV
- Familiarity with
bio-applied-cite-seq-integration is useful for the analogous ADT-normalization tradeoffs (CLR vs DSB parallels arcsinh vs logicle here)
Reading and Compensating FCS Files
Goal: load an FCS file, inspect channels/keywords, and correct fluorescence spillover so signal reflects single fluorophores rather than mixed detector output.
Approach: flowkit.Sample parses the FCS TEXT/DATA segments (via flowio internally) and keeps raw, compensated, and transformed event arrays addressable by source=. Always compensate before transforming — logicle/arcsinh parameters assume a compensated scale, and re-applying transforms after apply_compensation is automatic.
import flowkit as fk
import numpy as np
def load_and_compensate(fcs_path: str, comp_csv: str | None = None) -> fk.Sample:
"""Load an FCS file and apply spillover compensation.
fcs_path: path to a .fcs (2.0/3.0/3.1) file.
comp_csv: optional path to a CSV compensation matrix (detector names as
header row); if None, use the matrix embedded in the FCS $SPILL keyword.
Returns the Sample with a 'comp' event source available.
"""
sample = fk.Sample(fcs_path)
if comp_csv is not None:
comp_matrix = fk.Matrix(comp_csv, detectors=sample.pnn_labels)
sample.apply_compensation(comp_matrix)
elif sample.metadata.get("spill") is not None:
sample.apply_compensation(sample.metadata["spill"])
else:
raise ValueError(f"no spillover matrix found or supplied for {fcs_path}")
return sample
if __name__ == "__main__":
channels = ["FSC-A", "SSC-A", "FITC-A", "PE-A"]
rng = np.random.default_rng(0)
events = rng.normal(5000, 500, size=(1000, 4))
events[:, 3] += 0.3 * events[:, 2]
s = fk.Sample(events, channel_labels=channels, sample_id=)
spill = fk.Matrix(
np.array([[, ], [, ]]),
detectors=[, ],
)
s.apply_compensation(spill)
comp_events = s.get_events(source=)
comp_events.shape == events.shape
(, comp_events[:, ].mean().())
Logicle / Arcsinh Transforms for Display and Analysis
Goal: rescale compensated fluorescence so dim/negative and bright/positive cells are both visible, without the artificial banding a plain log transform causes near and below zero.
Approach: logicle is a data-driven biexponential (the FlowJo/Cytobank default) parameterized by param_t (top of scale, e.g. instrument max ~262144 for 18-bit), param_w (width of the linear region around zero, from negative-population spread), param_m (number of decades), and param_a (extra negative decades). Arcsinh with a fixed cofactor is simpler and standard for CyTOF/spectral panels (cofactor ~5 for mass cytometry, ~150-1000 for fluorescence).
import flowkit as fk
import numpy as np
def transform_fluorescence(sample: fk.Sample, fluoro_channels: list[str],
method: str = "logicle") -> fk.Sample:
"""Apply a display/analysis transform to compensated fluorescence channels.
sample: a Sample that already has compensation applied via apply_compensation.
fluoro_channels: PnN channel names to transform (exclude FSC/SSC/Time).
method: 'logicle' (biexponential) or 'asinh' (fixed-cofactor arcsinh).
"""
if method == "logicle":
xform = fk.transforms.LogicleTransform(param_t=262144, param_w=0.5, param_m=4.5, param_a=0)
elif method == "asinh":
xform = fk.transforms.AsinhTransform(param_t=262144, param_m=4.5, param_a=0)
else:
raise ValueError("method must be 'logicle' or 'asinh'")
sample.apply_transform({ch: xform for ch in fluoro_channels})
return sample
if __name__ == "__main__":
xform = fk.transforms.LogicleTransform(param_t=262144, param_w=0.5, param_m=4.5, param_a=0)
raw = np.array([-50.0, 0.0, 500.0, 262144.0]).reshape(-1, 1)
scaled = xform.apply(raw).ravel()
assert 0.0 <= scaled.() scaled.() <= +
np.(np.diff(scaled) > ),
(, scaled.())
Gating Hierarchies and Population Statistics
Goal: encode a gating tree (e.g. Singlets -> Live -> Lymphocytes -> CD3+) once and apply it consistently across many samples, then pull per-gate counts and percentages for reporting.
Approach: a Session bundles Samples with one GatingStrategy; gates are added by gate_path tuples starting at 'root'. RectangleGate/PolygonGate dimensions reference channel names plus optional transformation_ref. After analyze_samples, get_gate_membership returns a boolean event mask per gate and get_gate_counts/the GatingResults.report DataFrame give ready-made % of parent and % of total.
import flowkit as fk
import numpy as np
def build_and_run_gating(sample: fk.Sample) -> "fk.gating_results.GatingResults":
"""Build a two-level gating hierarchy and analyze one sample.
sample: Sample with FSC-A/SSC-A (scatter) and FITC-A (marker) channels,
already compensated (transform is applied inline via transformation_ref).
Hierarchy: root -> Lymphocytes (scatter gate) -> FITC+ (marker gate).
"""
strategy = fk.GatingStrategy()
scatter_dims = [
fk.Dimension("FSC-A", range_min=2000, range_max=8000),
fk.Dimension("SSC-A", range_min=500, range_max=6000),
]
lymph_gate = fk.gates.RectangleGate("Lymphocytes", scatter_dims)
strategy.add_gate(lymph_gate, gate_path=("root",))
marker_dim = [fk.Dimension("FITC-A", range_min=1000, range_max=None)]
pos_gate = fk.gates.RectangleGate("FITC+", marker_dim)
strategy.add_gate(pos_gate, gate_path=("root", "Lymphocytes"))
session = fk.Session(gating_strategy=strategy)
session.add_samples(sample)
session.analyze_samples()
return session.get_gating_results(sample.id)
if __name__ == "__main__":
channels = ["FSC-A", "SSC-A", "FITC-A"]
rng = np.random.default_rng(1)
events = np.column_stack([
rng.normal(5000, 800, 2000),
rng.normal(3000, 700, 2000),
rng.exponential(1500, ),
])
demo_sample = fk.Sample(events, channel_labels=channels, sample_id=)
results = build_and_run_gating(demo_sample)
report = results.report
report[].values
(report[[, , ]])
Pitfalls
- Compensate before transforming, never after. Logicle/arcsinh
param_w/cofactor choices assume a compensated scale; transforming raw spillover-contaminated data produces gates that don't reproduce in FlowJo.
$SPILL isn't always present or correct. Some instrument exports embed a stale or acquisition-only matrix — always visually confirm compensation (no diagonal smear) rather than trusting the keyword blindly.
- Logicle
param_w needs real data, not defaults. A param_w too small clips negative populations into a spike at zero; too large flattens resolution near zero. Estimate it from the actual negative-population spread per channel, not one global value.
- Gate order and
gate_path matter. FlowKit's add_gate requires the full ordered ancestor tuple; a wrong or misspelled path silently creates a disconnected gate rather than raising, and it won't appear in the intended hierarchy's report rows.
- FCS 3.1 vs 2.0 keyword differences. Older FCS 2.0 files may lack
$SPILL/standardized channel naming ($PnS vs $PnN); check both when writing code meant to run across archives spanning cytometer generations.
See Also
bio-applied-cite-seq-integration — analogous background/scale normalization problem (CLR/DSB) for CITE-seq ADT counts
bio-applied-immune-repertoire — downstream immunophenotyping once cytometry populations are gated
bio-applied-clinical-genomics — integrating flow-defined cell fractions with clinical/genomic variables
bio-applied-statistics-for-bioinformatics — group comparisons and multiple-testing correction for population-frequency statistics