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.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add Pavel-Kravchenko/Bioinformatics --skill bio-applied-flow-cytometry --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Bio Applied Flow Cytometry?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/pavel-kravchenko-bio-applied-flow-cytometry)More formats (shields.io, HTML) on the badges page.
---
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.
```python
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__":
# self-check with a synthetic 2-detector Sample (no .fcs file needed)
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] # simulate FITC->PE spillover
s = fk.Sample(events, channel_labels=channels, sample_id="synthetic")
spill = fk.Matrix(
np.array([[1.0, 0.0], [0.3, 1.0]]),
detectors=["FITC-A", "PE-A"],
)
s.apply_compensation(spill)
comp_events = s.get_events(source="comp")
assert comp_events.shape == events.shape
print("compensated PE-A mean:", comp_events[:, 3].mean().round(1))
```
## 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).
```python
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.min() and scaled.max() <= 1.0 + 1e-6
assert np.all(np.diff(scaled) > 0), "logicle transform must be monotonic"
print("logicle-scaled:", scaled.round(3))
```
## 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.
```python
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, 2000),
])
demo_sample = fk.Sample(events, channel_labels=channels, sample_id="demo")
results = build_and_run_gating(demo_sample)
report = results.report
assert "FITC+" in report["gate_name"].values
print(report[["gate_name", "count", "absolute_percent"]])
```
## 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
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!