opencode-skills-collection 4.1.7 → 4.1.8

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.
Files changed (32) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +7 -1
  2. package/bundled-skills/court/SKILL.md +141 -0
  3. package/bundled-skills/court/references/jurors.json +14 -0
  4. package/bundled-skills/court/scripts/court.py +348 -0
  5. package/bundled-skills/darkmoon-pentest/SKILL.md +123 -0
  6. package/bundled-skills/lognorm/SKILL.md +86 -0
  7. package/bundled-skills/lognorm/references/playbooks.md +76 -0
  8. package/bundled-skills/loki-mode/examples/todo-app-generated/backend/package-lock.json +6 -6
  9. package/bundled-skills/loki-mode/examples/todo-app-generated/backend/package.json +1 -1
  10. package/bundled-skills/parallel-search-mcp/SKILL.md +12 -0
  11. package/bundled-skills/scarf-single-cell/SKILL.md +326 -0
  12. package/bundled-skills/scarf-single-cell/references/LICENSE.md +29 -0
  13. package/bundled-skills/scarf-single-cell/references/clustering-and-embedding.md +270 -0
  14. package/bundled-skills/scarf-single-cell/references/data-access.md +269 -0
  15. package/bundled-skills/scarf-single-cell/references/features-and-graphs.md +279 -0
  16. package/bundled-skills/scarf-single-cell/references/gene-blacklists.md +182 -0
  17. package/bundled-skills/scarf-single-cell/references/integration-and-comparisons.md +295 -0
  18. package/bundled-skills/scarf-single-cell/references/markers-and-annotation.md +280 -0
  19. package/bundled-skills/scarf-single-cell/references/performance-and-export.md +291 -0
  20. package/bundled-skills/scarf-single-cell/references/pipeline-runs-and-artifacts.md +273 -0
  21. package/bundled-skills/scarf-single-cell/references/plotting.md +260 -0
  22. package/bundled-skills/scarf-single-cell/references/quality-control.md +290 -0
  23. package/bundled-skills/session-handoff/SKILL.md +112 -0
  24. package/bundled-skills/shark/SKILL.md +134 -0
  25. package/bundled-skills/shark/references/investors.json +12 -0
  26. package/bundled-skills/shark/scripts/shark.py +302 -0
  27. package/bundled-skills/telegram/assets/boilerplate/nodejs/package-lock.json +6 -6
  28. package/bundled-skills/telegram/assets/boilerplate/nodejs/package.json +1 -1
  29. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/package-lock.json +6 -6
  30. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/package.json +1 -1
  31. package/package.json +1 -1
  32. package/skills_index.json +201 -0
@@ -0,0 +1,295 @@
1
+ # Study design, integration, and condition comparisons
2
+
3
+ Identify donors, samples, batches and conditions, correct a technical batch with Harmony and score
4
+ it, then compare composition and expression at the donor level. Docs: <https://scarf.readthedocs.io/en/latest/tutorials/batch_correction.html>, and in the same folder
5
+ `pseudobulk_and_differential_expression.html`, `condition_comparisons.html`,
6
+ `dataset_merging.html`, `mapping_and_label_transfer.html`, `downsampling.html`.
7
+
8
+ ## When to use
9
+
10
+ - The store has several donors, samples, batches or conditions (most CELLxGENE atlases), or a
11
+ layout separates by donor or batch and you must decide whether to correct it.
12
+ - The question compares conditions (abundance of a population, or expression within one), or you
13
+ need to combine stores, map a query onto a fixed reference, or work on fewer cells.
14
+
15
+ ## Key concepts
16
+
17
+ - **Units.** A donor is the biological replicate; a sample is one specimen from one donor; a
18
+ library is one capture; a batch is a processing group. Cells are never replicates: the n of a
19
+ condition comparison is the number of donors. Sample, library and batch columns may be absent.
20
+ - **Nesting.** Donors nested in conditions need between-donor tests. A donor sampled at several
21
+ timepoints is repeated measures: pair on the donor. A donor in both arms needs the rules below.
22
+ - **Denominators.** A share of all cells moves when any other population moves. A state's share of
23
+ its lineage parent (an activated state as a share of CD14 monocytes) does not depend on the
24
+ parent's abundance. Excluding contaminants (erythrocytes, platelets) or unresolved cells raises
25
+ every remaining share by `1 / (1 - excluded share)`, a factor that differs between samples.
26
+ - **Harmony corrects coordinates only.** `run_harmony` returns a `batch_correction` ref that
27
+ replaces PCA for ANN, neighbours, graph, UMAP and clustering; counts are untouched. A batch
28
+ holding one condition loses that condition's signal; Scarf ran silently with `condition`.
29
+ - **Metrics are evidence, not a verdict.** `metric_*` take exact neighbour or graph refs and a live
30
+ column and return a float. iLISI (mixing) should rise while cLISI and connectivity hold.
31
+ - **What Scarf tests.** `run_statistical_testing` runs donor-level rank tests (a
32
+ `statistical_tests` artifact; several keys share one BH pass). No count GLM (DESeq2, edgeR) or
33
+ compositional model (scCODA, propeller): export pseudobulk counts ("Export pseudobulk counts" in
34
+ `performance-and-export.md`) and model one population at a time with donor-level terms.
35
+
36
+ ## Recipes
37
+
38
+ ### Audit the study design
39
+
40
+ Later recipes reuse these imports and `ds`. All column names are placeholders; `cell_type` stands
41
+ for your own annotation column, never for author labels that are held out.
42
+
43
+ ```python
44
+ import re
45
+
46
+ import numpy as np
47
+ import pandas as pd
48
+ import scarf
49
+ from scarf.plotting import CellField
50
+
51
+ ds = scarf.DataStore("analysis.zarr", min_features_per_cell=-1) # reopen; see data-access.md
52
+ hits = re.compile(r"donor|individual|patient|subject|sample|library|batch|"
53
+ r"condition|disease|treatment|time|day|assay|sex", re.I)
54
+ print([c for c in ds.cells.columns if hits.search(c)])
55
+
56
+ meta = ds.cells.to_pandas_dataframe(["donor_id", "condition", "timepoint", "batch"], key="I")
57
+ print(meta.isna().sum()) # resolve missing labels first
58
+ print(meta.groupby("donor_id").agg(
59
+ cells=("condition", "size"), conditions=("condition", "nunique"),
60
+ timepoints=("timepoint", "nunique"), batches=("batch", "nunique"),
61
+ ))
62
+ print(meta.drop_duplicates(["donor_id", "condition"])["condition"].value_counts()) # donors per arm
63
+ units = meta.drop_duplicates(["donor_id", "batch", "condition"])
64
+ print(pd.crosstab(units["batch"], units["condition"])) # donors per batch x condition
65
+ ```
66
+
67
+ `conditions == 1` for every donor means nested; `timepoints > 1` means repeated measures;
68
+ `conditions > 1` puts a donor in two arms. A batch row with donors in one condition only is
69
+ confounded with it. No sample column: join donor and timepoint strings (every row, none missing)
70
+ and `ds.cells.insert("sample_id", values, overwrite=True)`; `insert` writes to the store at once.
71
+
72
+ ### Harmony through the pipeline, with a matched baseline
73
+
74
+ ```python
75
+ design_cols = ("batch", "donor_id", "sample_id", "condition") # Harmony columns first
76
+ native = ds.pipeline.run(label="native", snapshot_columns=design_cols)
77
+ corrected = ds.pipeline.run(
78
+ label="harmony_batch", harmony_batch_columns=["batch"], snapshot_columns=design_cols,
79
+ )
80
+ assert native["pca"] == corrected["pca"] # same frozen cells and PCA; only the graph differs
81
+ print(corrected["harmony"]) # ArtifactRef(kind='batch_correction')
82
+ ```
83
+
84
+ The run's input snapshot is `names`, filter columns, Harmony columns, then `snapshot_columns`, in
85
+ order; another list or order gives a new analysis selection and recomputes everything (24
86
+ artifacts created, then 16 reused by the Harmony run; 10x 5K PBMC docs dataset). `run.cells`
87
+ exposes only `snapshot_columns`: read a filter or Harmony column with
88
+ `ds.cells.fetch_all(col)[run.cells.fetch_all("I")]`. Doublet scores still use plain PCA.
89
+
90
+ As a branch of a run, `harmony = ds.run_harmony(run["pca"], ["batch"])` returns a ref to pass to
91
+ `build_ann_index` in place of PCA (chain in `features-and-graphs.md`; keep the run's `k`). Score it
92
+ below with the run's `neighbors` and `connectivity_map` versus the new pair.
93
+
94
+ ### Score native versus corrected
95
+
96
+ A shuffled batch column gives the iLISI of perfect mixing for the same graph and `k`. Label
97
+ concordance (ARI) needs two clustering refs on the same frozen cell selection.
98
+
99
+ ```python
100
+ rng = np.random.default_rng(0)
101
+ ds.cells.insert("batch_shuffled", rng.permutation(ds.cells.fetch_all("batch")), overwrite=True)
102
+
103
+ def scores(neighbors, graph):
104
+ return {
105
+ "iLISI batch": ds.metric_ilisi("batch", neighbors),
106
+ "iLISI ceiling": ds.metric_ilisi("batch_shuffled", neighbors),
107
+ "batch mixing": ds.metric_proportional_batch_mixing("batch", neighbors),
108
+ "cLISI cell_type": ds.metric_clisi("cell_type", neighbors),
109
+ "connectivity cell_type": ds.metric_graph_connectivity("cell_type", graph),
110
+ }
111
+
112
+ print(pd.DataFrame({
113
+ "native": scores(native["neighbors"], native["connectivity_map"]),
114
+ "harmony": scores(corrected["neighbors"], corrected["connectivity_map"]),
115
+ }).T.round(3))
116
+ print(ds.metric_label_concordance(native["clusters"], corrected["clusters"], metric="ari"))
117
+ for r in (native, corrected): # a condition-only cluster that vanishes signals overcorrection
118
+ print(pd.crosstab(r.cells.fetch("clusters"), r.cells.fetch("condition")))
119
+ ```
120
+
121
+ ### A donor sampled in two arms
122
+
123
+ Example: a donor's first sample is in arm A and a later one in arm B (ventilation status changed
124
+ between timepoints). Decide per comparison and write the rule down before testing:
125
+
126
+ - **Pool to one value per donor** when all of its samples fall in one group of this comparison
127
+ (patients versus healthy donors). Pool donors with several samples in one arm the same way.
128
+ - **Drop it from the between-group test** of the arms it spans (A versus B). Its samples share
129
+ genotype and history: counting it twice makes the groups dependent and invents a donor.
130
+ - **Describe it within the donor only.** Its B minus A change is one pair (n = 1): report the two
131
+ values, never a p-value. An exact Wilcoxon needs 6 such pairs to reach 0.03125.
132
+ - If dropping leaves 3 versus 3 donors (minimum exact two-sided p 0.1), report n, medians and
133
+ effect sizes without a test. Add a sensitivity run that excludes the donor everywhere.
134
+ - `sample_by="donor_id"` makes Scarf refuse such a donor ("Each sample must belong to exactly one
135
+ group"); `sample_by="sample_id"` silently counts it in both arms.
136
+
137
+ ### Composition per donor with exact tests
138
+
139
+ Per-unit proportions (a donor's samples pooled), two-sided Mann-Whitney between independent donor
140
+ groups, BH across populations, and the n = 1 rows of two-arm donors.
141
+
142
+ ```python
143
+ from math import comb
144
+
145
+ from scipy.stats import PermutationMethod, false_discovery_control, mannwhitneyu
146
+
147
+ run = ds.pipeline.open(label="baseline")
148
+ in_run = np.asarray(run.cells.fetch_all("I"), dtype=bool)
149
+ cells = ds.cells.to_pandas_dataframe(["donor_id", "sample_id", "condition", "cell_type",
150
+ "cell_state"])[in_run]
151
+ excluded = ["Erythrocyte", "Platelet", "Unresolved"] # your contaminant and unresolved labels
152
+ drop = cells["cell_type"].isin(excluded)
153
+ print(drop.groupby(cells["sample_id"]).mean().round(3)) # report the removed share per sample
154
+ kept = cells[~drop]
155
+ arms = kept.groupby("donor_id")["condition"].nunique()
156
+ two_arm = arms.index[arms > 1].tolist() # donors sampled in both arms
157
+
158
+ per_sample = pd.crosstab(kept["sample_id"], kept["cell_type"], normalize="index")
159
+ independent = kept[~kept["donor_id"].isin(two_arm)] # two-arm donors leave the tests
160
+ per_donor = pd.crosstab(independent["donor_id"], independent["cell_type"], normalize="index")
161
+ arm = independent.groupby("donor_id")["condition"].first()
162
+
163
+ def exact_tests(props, arm, g1, g2):
164
+ rows = []
165
+ for pop in props.columns:
166
+ x = props[pop].reindex(arm.index[arm == g1]).dropna() # units without a value drop out
167
+ y = props[pop].reindex(arm.index[arm == g2]).dropna()
168
+ row = {"population": pop, "n_1": len(x), "n_2": len(y), "median_1": x.median(),
169
+ "median_2": y.median(), "auc": np.nan, "p_exact": np.nan}
170
+ if min(len(x), len(y)) >= 2: # as in Scarf: fewer than 2 units in a group is not tested
171
+ exact = comb(len(x) + len(y), len(x)) <= 100_000 # Scarf's switch to asymptotic
172
+ method = PermutationMethod(n_resamples=np.inf) if exact else "asymptotic"
173
+ u = mannwhitneyu(x, y, method=method) # the exact null counts ties
174
+ row.update(auc=u.statistic / (len(x) * len(y)), p_exact=u.pvalue)
175
+ rows.append(row)
176
+ table = pd.DataFrame(rows)
177
+ tested = table["p_exact"].notna() # untested rows keep NaN and stay out of the correction
178
+ table["p_bh"] = np.nan
179
+ table.loc[tested, "p_bh"] = false_discovery_control(table.loc[tested, "p_exact"], method="bh")
180
+ return table
181
+
182
+ print(exact_tests(per_donor, arm, "ctrl", "case").round(4))
183
+ parent = kept[kept["cell_type"] == "CD14 monocyte"] # lineage parent as denominator
184
+ state = (parent["cell_state"] == "CD14 monocyte (state 1)").groupby(parent["donor_id"]).mean()
185
+ print(exact_tests(state.to_frame("state 1 of CD14 monocytes"), arm, "ctrl", "case").round(4))
186
+ sample_info = kept.groupby("sample_id")[["donor_id", "condition"]].first()
187
+ print(per_sample.join(sample_info)[lambda t: t["donor_id"].isin(two_arm)].round(3)) # n = 1
188
+ ```
189
+
190
+ The same test in Scarf (a saved artifact): a 0/1 column averaged per donor is the donor's share,
191
+ and the cell selection is the denominator (snapshot the parent's cells for a parent share).
192
+
193
+ ```python
194
+ labels, donors = ds.cells.fetch_all("cell_type"), ds.cells.fetch_all("donor_id")
195
+ keys = []
196
+ for pop in per_donor.columns:
197
+ keys.append("is_" + re.sub(r"\W+", "_", pop))
198
+ ds.cells.insert(keys[-1], (labels == pop).astype(np.float64), overwrite=True)
199
+ counted = in_run & ~np.isin(labels, excluded) & ~np.isin(donors, two_arm)
200
+ ds.cells.insert("in_composition", counted, overwrite=True)
201
+ result = ds.run_statistical_testing(
202
+ keys, CellField("condition"), cell_selection=ds.snapshot_cell_selection("in_composition"),
203
+ groups=["ctrl", "case"], test="mann_whitney", sample_by="donor_id",
204
+ )
205
+ print(result.p_value_method, pd.concat([result.tables[k].assign(key=k) for k in keys]))
206
+ ```
207
+
208
+ Both routes gave identical exact and BH p-values on a synthetic design. Report n per group,
209
+ medians, AUC (`U / (n_1 n_2)`; 0.5 is no shift) and BH values, then repeat with all cells as the
210
+ denominator. `ds.plots.composition(..., kind="per_sample", study_design=...)` draws a strip of
211
+ sample proportions per category (its intervals count samples, so a two-sample donor counts twice);
212
+ `kind="stacked", sample_by=...` draws one stacked bar per sample.
213
+
214
+ ### Donor-level expression tests
215
+
216
+ ```python
217
+ is_b = in_run & (labels == "B cell")
218
+ ds.cells.insert("is_b_cell", is_b, overwrite=True)
219
+ ds.cells.insert("one_arm_donor", ~np.isin(donors, two_arm), overwrite=True)
220
+ b_cells = ds.snapshot_cell_selection("is_b_cell") # frozen cell_selection ref
221
+ between = ds.run_statistical_testing( # donors nested in condition
222
+ ["CD79A", "MS4A1"], CellField("condition"), cell_selection=b_cells, subset_by="one_arm_donor",
223
+ groups=["ctrl", "case"], test="mann_whitney", sample_by="donor_id",
224
+ )
225
+ within = ds.run_statistical_testing( # same donor, two timepoints
226
+ ["CD79A", "MS4A1"], CellField("timepoint"), cell_selection=b_cells,
227
+ groups=["t0", "t1"], test="wilcoxon", sample_by="sample_id", pair_by="donor_id",
228
+ )
229
+ print(between.tables["CD79A"], within.tables["CD79A"], between.artifact, sep="\n")
230
+ ```
231
+
232
+ Expression is averaged per sample, then ranked; `groups` fixes the order (`mean_difference =
233
+ mean_1 - mean_2`). Mann-Whitney is exact up to 100,000 group splits (`result.p_value_method`);
234
+ Wilcoxon keeps donors seen at both levels (`n_pairs`). Reload: `ds.get_statistical_tests(ref)`.
235
+
236
+ ### Merge, map, downsample
237
+
238
+ `scarf.DataStoreMerge(datasets=[ds_a, ds_b], zarr_path=..., names=[...], assays=["RNA"],
239
+ prepend_text="orig", source_column="dataset", overwrite=True).dump()` gives an uncorrected joint
240
+ store; drop `is_primary_data == False` cells first and score iLISI before Harmony (not executed:
241
+ `MemoryError` under a 3 GB budget). Mapping keeps a reference fixed; the query is another store.
242
+
243
+ ```python
244
+ reference = ds.get_mapping_reference(ds.build_mapping_reference(native["neighbors"]))
245
+ query = scarf.DataStore("query.zarr", default_assay="RNA")
246
+ mapping_ref = query.run_mapping(reference, query.snapshot_cell_selection("I"), save_k=5)
247
+ print(query.get_mapping_result(mapping_ref, reference=reference).diagnostics)
248
+ labels = query.get_target_classes(mapping_ref, "cell_type", reference=reference,
249
+ threshold_fraction=0.6)
250
+ query.cells.insert("transferred_cell_type", labels.to_numpy(), overwrite=True) # "NA" = abstained
251
+ ```
252
+
253
+ `ds.run_topacedo_sampler(graph, paris, max_sampling_rate=0.1)` keeps graph coverage, not
254
+ proportions (`sampled` is in graph-row order); to balance donors, snapshot a seeded mask instead.
255
+
256
+ ## Parameters that matter
257
+
258
+ | Parameter | Default | Change when |
259
+ | --- | --- | --- |
260
+ | `harmony_batch_columns` / `batch_columns` | `None` (off) / required | Technical columns only |
261
+ | `neighbors_k` / `query_neighbors(k=)` | 11 | 21 or more when LISI metrics decide |
262
+ | metric `perplexity` | `None` = `floor(k/3)` | Keep fixed across graphs; capped at `k/3` |
263
+ | `sample_by` / `pair_by` | `None` | Always `sample_by`; `pair_by` only if repeated |
264
+ | `sample_stat` | `"mean"` | `"fraction"` + `expression_cutoff` for detection |
265
+ | `subset_by` | `None` | A boolean column, e.g. to drop two-arm donors |
266
+
267
+ ## Check before moving on
268
+
269
+ - Every sample maps to one donor and one condition; donors per arm is the n; read confounding from
270
+ donors (`units`), not cells.
271
+ - Each donor counts once per test: `n_1` and `n_2` are donors, two-arm donors are pooled or
272
+ dropped as decided, and the denominator and the excluded share per sample are reported.
273
+ - After Harmony: iLISI moves toward the shuffled ceiling, cLISI and connectivity do not drop, no
274
+ condition-only cluster vanishes, and the ARI against native is explainable.
275
+
276
+ ## Pitfalls
277
+
278
+ - `insert` stores `None` as `""` (Harmony silently treats it as a level; metrics raise) and `pd.NA`
279
+ as the literal `"<NA>"` (a real group everywhere). `to_pandas_dataframe` shows missingness.
280
+ - Correcting on `donor_id` or `sample_id` with a donor-level condition aligns donors and can absorb
281
+ the condition. Prefer library or batch; otherwise recheck condition-specific clusters.
282
+ - iLISI depends on `k`: a random synthetic batch scored 0.34, 0.67 and 0.76 at `k` 11, 21 and 31
283
+ (10x 5K PBMC docs dataset). CELLxGENE `cell_type` may come from the authors' own integration,
284
+ biasing cLISI toward it.
285
+ - `pair_by` on donors nested in conditions raises "Duplicate (pair, group) rows"; use `sample_by`.
286
+ - `scipy.stats.mannwhitneyu(method="exact")` ignores ties (donors at 0 tie), so it disagrees with
287
+ Scarf; `PermutationMethod(n_resamples=np.inf)` matches it but is exhaustive (12 versus 12: 20 s).
288
+ - Without `cell_selection`, a `CellField` grouping covers every stored cell, `I` False included.
289
+ - Marker search, Welch and ANOVA treat cells as observations; never use them for condition claims.
290
+ - A Symphony-style mapping correction (`query_batches`) needs a technical batch, never a condition.
291
+
292
+ ## See also
293
+
294
+ - `quality-control.md`, `features-and-graphs.md`, `markers-and-annotation.md`, `plotting.md`
295
+ (`composition`), `pipeline-runs-and-artifacts.md`, `performance-and-export.md` (pseudobulk).
@@ -0,0 +1,280 @@
1
+ # Markers and annotation
2
+
3
+ Find cluster markers, turn marker evidence into cautious cell-type labels, separate progenitors
4
+ from multiplets, score gene sets and the cell cycle, and run a final comparison with existing
5
+ labels. Docs: <https://scarf.readthedocs.io/en/latest/tutorials/annotation.html>, <https://scarf.readthedocs.io/en/latest/tutorials/gene_set_scoring.html>,
6
+ <https://scarf.readthedocs.io/en/latest/tutorials/cell_cycle.html>, <https://scarf.readthedocs.io/en/latest/reference/api/datastore.html>.
7
+
8
+ ## When to use
9
+
10
+ - A partition is chosen (`clustering-and-embedding.md`) and its clusters need names.
11
+ - You need marker tables, panel checks, multiplet checks, signature scores or cell-cycle phases.
12
+ - Labels are final and a held-out reference exists for a post-hoc evaluation.
13
+
14
+ ## Key concepts
15
+
16
+ - `run_marker_search(clusters, features=...)` takes an exact `cluster_labels` or `cluster_cut` ref
17
+ and a feature-selection ref, streams counts once (remote for mounted stores) and returns
18
+ `ArtifactRef(kind="marker_table")`. `run["markers"]` is the table for `run["clusters"]`.
19
+ - Columns: `score` (Scarf specificity, 0 to 1, summing to 1 across groups for each gene; near
20
+ 1/n_groups means ubiquitous), `mean`/`mean_rest`, `frac_exp`/`frac_exp_rest`, `fold_change`,
21
+ `auc`, two-sided Mann-Whitney `p_value` and `p_value_adjusted` (BH within each group). They are
22
+ one-versus-rest statistics over cells, not replicate-aware differential expression.
23
+ - `get_markers` returns a DataFrame with a string `group_id`, rows sorted by `score` within each
24
+ group and groups in natural order (`"2"` before `"10"`). Defaults `min_score=0.25`,
25
+ `min_frac_exp=0.2` hide most genes; pass `-1` for both to get every gene for every group.
26
+ - A label is a hypothesis. It needs named positive markers, low lineage-negative markers and a
27
+ plausible QC and doublet profile. Keep an explicit `unresolved` label instead of forcing a name.
28
+ - Doublet scores rise with RNA content, so plasma cells and other high-RNA types score high as
29
+ whole clusters. Judge doublets within a lineage and per cell, not by a global cut.
30
+ - No public API saves user-assigned labels as an artifact. Store them as a live column with
31
+ `ds.cells.insert` (mutable, unversioned, written into the store).
32
+ - AUCell and WAGGR return `kind="enrichment_scores"` (activity scores, not p-values); cell-cycle
33
+ scoring returns `kind="cell_cycle"` (`phase`, `s_score`, `g2m_score`). Rows follow the selection.
34
+
35
+ ## Recipes
36
+
37
+ ### Get and read marker tables
38
+
39
+ ```python
40
+ import numpy as np
41
+ import pandas as pd
42
+ import scarf
43
+
44
+ scarf.configure_output(level="WARNING", progress=False)
45
+ ds = scarf.DataStore("analysis.zarr", nthreads=4, min_features_per_cell=-1)
46
+ run = ds.pipeline.open(label="baseline")
47
+ clusters, markers = run["clusters"], run["markers"]
48
+ # Another partition needs its own table (one pass over counts):
49
+ markers_res1 = ds.run_marker_search(run["leiden_1.0"], features=run["feature_universe"])
50
+
51
+ top = ds.get_markers(marker=markers) # min_score=0.25, min_frac_exp=0.2
52
+ print(top.groupby("group_id", sort=False).head(8)
53
+ .groupby("group_id", sort=False)["feature_name"].agg(", ".join))
54
+ one = ds.get_markers(marker=markers, group_id=3) # an int matches the label "3"
55
+ print(one[["feature_name", "score", "frac_exp", "frac_exp_rest", "auc", "p_value_adjusted"]].head())
56
+ everything = ds.get_markers(marker=markers, min_score=-1, min_frac_exp=-1) # all genes x groups
57
+ # ds.export_markers_to_csv(markers, "markers.csv") writes marker names per group, wide format
58
+ ```
59
+
60
+ ### Tabulate a canonical marker panel
61
+
62
+ ```python
63
+ panel = ["CD3E", "IL7R", "CD8A", "NKG7", "GNLY", "MS4A1", "CD79A", "MZB1", "JCHAIN", "CD14",
64
+ "LYZ", "FCGR3A", "FCER1A", "LILRA4", "PPBP", "HBB", "CD34", "MKI67"]
65
+ order = list(dict.fromkeys(everything["group_id"])) # pivot would sort "10" before "2"
66
+ hits = everything[everything["feature_name"].isin(panel)]
67
+ frac = (hits.pivot(index="group_id", columns="feature_name", values="frac_exp")
68
+ .reindex(index=order, columns=[g for g in panel if g in set(hits["feature_name"])]))
69
+ score = hits.pivot(index="group_id", columns="feature_name", values="score").reindex_like(frac)
70
+ print(frac.round(2), score.round(2), sep="\n\n") # detection versus exclusivity
71
+ ```
72
+
73
+ ### Flag clusters for review: QC, doublets, multiplets and progenitors
74
+
75
+ Per cell, count lineages whose two specific genes are both detected, and the progenitor program
76
+ genes detected, from one raw-count panel export. Ambient genes (LYZ, HBB, JCHAIN, Ig) are left out
77
+ because they are detected almost everywhere.
78
+
79
+ ```python
80
+ in_run = run.cells.fetch_all("I").astype(bool)
81
+ review = run.cells.to_pandas_dataframe(["ids", "clusters", "doublet_score"]).set_index("ids")
82
+ review = review.assign(**{c: ds.cells.fetch_all(c)[in_run]
83
+ for c in ("RNA_nCounts", "RNA_percentMito")})
84
+ lineages = {"T": ["CD3D", "CD3E"], "B": ["MS4A1", "CD79B"], "myeloid": ["CD14", "FCN1"],
85
+ "plasma": ["MZB1", "TNFRSF17"]}
86
+ program = ["CD34", "PRSS57", "SPINK2", "CYTL1", "SOX4"] # SOX4 alone is broadly detected
87
+ wanted = list(dict.fromkeys(sum(lineages.values(), []) + program))
88
+ present = [g for g in wanted if g in set(ds.RNA.feats.fetch_all("names"))]
89
+ counts = ds.to_anndata(from_assay="RNA", feature_names=present) # obs_names are cell ids
90
+ hit = (pd.DataFrame(counts.X.toarray() > 0, index=counts.obs_names, columns=present)
91
+ .reindex(index=review.index, columns=wanted, fill_value=False))
92
+ review["n_lineages"] = sum(hit[genes].all(axis=1).astype(int) for genes in lineages.values())
93
+ review["progenitor"] = hit["CD34"] & (hit[program[1:]].sum(axis=1) >= 2) # coherent program
94
+ review["doublet_rank"] = review.groupby("clusters")["doublet_score"].rank(pct=True)
95
+ summary = review.groupby("clusters").agg(
96
+ n=("doublet_score", "size"), doublet=("doublet_score", "median"),
97
+ counts=("RNA_nCounts", "median"), mito=("RNA_percentMito", "median"),
98
+ two_lineages=("n_lineages", lambda s: (s >= 2).mean()), progenitor=("progenitor", "mean"))
99
+ summary["n_markers"] = (top["group_id"].value_counts()
100
+ .reindex(summary.index.astype(str), fill_value=0).to_numpy())
101
+ print(summary.round(2))
102
+ suspects = review[(review["n_lineages"] >= 2) & (review["doublet_rank"] >= 0.9)]
103
+ print(len(suspects), "cells co-detect two lineages and sit in their cluster's top doublet decile")
104
+ ```
105
+
106
+ Read the flags together, per cluster and per cell:
107
+
108
+ - Progenitor (HSPC): a coherent program in the same cells (CD34 with PRSS57, SPINK2, CYTL1; SOX4
109
+ supports) and few two-lineage cells. Primed progenitors add early genes of one lineage
110
+ (GATA2, KLF1, CPA3) that vary between cells, not mature markers of two lineages per cell.
111
+ - Multiplet: mature markers of two lineages in the same cells (T with myeloid, B or plasma with
112
+ T or myeloid), counts or features above both parent lineages, few markers of its own and a
113
+ high doublet rank. Single multiplets inside large clusters show up only in `suspects`.
114
+ - Doublet scores: compare a cluster with clusters of the same lineage, not the global median. A
115
+ whole plasma cluster scoring high is expected; a plasma subset co-detecting T genes is not.
116
+ - Ambiguous (a program plus two-lineage cells, or high counts without co-detection): keep
117
+ `unresolved` and record both readings.
118
+
119
+ ### Assign labels and store them
120
+
121
+ Write the evidence next to each label. The map below is a template with placeholder IDs: never
122
+ copy a map from another dataset. Clusters not named stay `unresolved`.
123
+
124
+ ```python
125
+ cluster_values = run.cells.fetch("clusters").astype(str)
126
+ labels = { # TEMPLATE: replace "<id>" and every gene with this dataset's own evidence
127
+ # "<id>": "CD14 monocyte", # + CD14, LYZ, FCN1 | - CD3E, MS4A1 | confidence high
128
+ # "<id>": "CD4 T", # + CD3E, IL7R, CD40LG | - CD8A, NKG7 | medium: CD4 sparse
129
+ # "<id>": "HSPC", # + CD34, PRSS57, SPINK2 | - no two-lineage cells | medium
130
+ # "<id>": "unresolved", # + CD3E with CD14 in the same cells, high counts | low
131
+ }
132
+ per_cell = pd.Series(cluster_values).map(labels).fillna("unresolved").to_numpy()
133
+ print(pd.Series(per_cell).value_counts())
134
+ annotation = np.full(ds.cells.N, "not analyzed", dtype=object)
135
+ annotation[in_run] = per_cell
136
+ ds.cells.insert("annotation_v1", annotation, overwrite=True) # live column, full cell axis
137
+ ds.cells.insert("annotation_v1_cells", in_run, overwrite=True)
138
+ named = sorted(int(c) for c, name in labels.items() if name != "unresolved")
139
+ keep = ds.select_cells(clusters, include=named) if named else run["analysis_cell_selection"]
140
+ ```
141
+
142
+ `select_cells(include=[])` raises, hence the guard; `keep` can seed a graph without unresolved
143
+ clusters. Plot evidence with `ds.plots.dotplot(features=..., group_by="annotation_v1",
144
+ cell_key="annotation_v1_cells")` and `ds.plots.marker_heatmap(marker=markers, topn=3)`.
145
+
146
+ ### Score gene sets with AUCell and WAGGR
147
+
148
+ ```python
149
+ net = pd.DataFrame({ # or scarf.read_gmt("sets.gmt"): one source-target row per gene
150
+ "source": ["T_cell"] * 4 + ["B_cell"] * 3 + ["Monocyte"] * 4 + ["NK"] * 4,
151
+ "target": ["CD3D", "CD3E", "TRAC", "IL7R", "MS4A1", "CD79A", "CD79B",
152
+ "CD14", "LYZ", "S100A8", "FCN1", "NKG7", "GNLY", "KLRD1", "PRF1"]})
153
+ cells = run["analysis_cell_selection"]
154
+ universe = ds.select_all_features(from_assay="RNA")
155
+ aucell = ds.run_aucell(net, cells, features=universe, tmin=3) # one pass over counts
156
+ waggr = ds.run_waggr(net.assign(weight=1.0), cells, features=universe, tmin=3)
157
+ result = ds.get_enrichment(aucell) # lazy; sources=[...] loads a subset
158
+ assert result.cell_selection == cells
159
+ scores = pd.DataFrame(result.data.compute(), columns=list(result.source_names))
160
+ print(scores.groupby(run.cells.fetch("clusters")).mean().round(2))
161
+ ```
162
+
163
+ AUCell ranks raw counts within each cell (0 to 1, `n_up` defaults to 5% of the universe). WAGGR
164
+ averages library-size-normalized expression with edge weights (unbounded, signed weights allowed).
165
+
166
+ ### Read or compute cell-cycle phase
167
+
168
+ ```python
169
+ phase = run.cells.to_pandas_dataframe(["clusters", "cell_cycle_phase", "s_score", "g2m_score"])
170
+ print(pd.crosstab(phase["clusters"], phase["cell_cycle_phase"], normalize="index").round(2))
171
+ cycle = ds.run_cell_cycle_scoring(keep) # new artifact; one pass over counts
172
+ print(pd.Series(np.asarray(ds.load_artifact(cycle)["phase"][:]).astype(str)).value_counts())
173
+ ```
174
+
175
+ ### Compare with existing labels (final post-hoc evaluation only)
176
+
177
+ Run once, after labels are frozen. Read the reference column here first, write the map from its
178
+ vocabulary before any crosstab, and never tune labels or parameters on the result.
179
+
180
+ ```python
181
+ import hashlib
182
+ from sklearn.metrics import adjusted_rand_score, classification_report
183
+
184
+ ds.cells.to_pandas_dataframe(["ids", "annotation_v1"]).to_csv("labels_v1_frozen.csv", index=False)
185
+ print("sha256", hashlib.sha256(open("labels_v1_frozen.csv", "rb").read()).hexdigest())
186
+ reference = pd.Series(ds.cells.fetch_all("author_cell_type")[in_run]).astype(str) # held out
187
+ print(sorted(reference.unique())) # the vocabulary only; now write the map below
188
+ to_reference = { # TEMPLATE: our label -> reference label, fixed before seeing any agreement
189
+ # "CD4 T": "<reference label>",
190
+ }
191
+ ours = pd.Series(ds.cells.fetch_all("annotation_v1")[in_run]).astype(str)
192
+ pred = ours.map(to_reference).fillna("unmatched")
193
+ print(pd.crosstab(pred, reference))
194
+ shared = sorted(set(to_reference.values()))
195
+ both = (pred != "unmatched") & reference.isin(shared) # drops unresolved and unmapped classes
196
+ print("ARI all cells", round(adjusted_rand_score(reference, pred), 3))
197
+ if both.any():
198
+ print("ARI mapped only", round(adjusted_rand_score(reference[both], pred[both]), 3))
199
+ print(classification_report(reference, pred, labels=shared, zero_division=0))
200
+ ```
201
+
202
+ Report precision and recall per class, ARI with and without ambiguous classes, and reference
203
+ classes with no counterpart. Disagreement is evidence: inspect large off-diagonal cells.
204
+
205
+ ## Canonical markers
206
+
207
+ Confirm with dataset markers and use two or more genes. Human symbols; mouse symbols are title
208
+ case (`Cd3e`, `Ptprc`, `Mki67`), and panel lookups are exact, so convert the panel.
209
+
210
+ | Population | Positive markers | Notes and negatives |
211
+ |---|---|---|
212
+ | T cell | CD3D, CD3E, TRAC | MS4A1, CD14 low |
213
+ | CD4 T | IL7R, CD40LG, CD4 (often poorly detected) | CD8A, CD8B low; CCR7, SELL, LEF1 mark naive T |
214
+ | CD8 T | CD8A, CD8B | effector GZMK, GZMH, NKG7 |
215
+ | Treg | FOXP3, IL2RA, CTLA4 | within CD4 T |
216
+ | MAIT, gamma-delta T | SLC4A10, KLRB1 (MAIT); TRDC, TRGC1 (gamma-delta) | often within CD8 or effector T |
217
+ | NK | NKG7, GNLY, KLRD1, KLRF1, NCAM1 | CD3E low; NKG7, GNLY also in CD8 effector T |
218
+ | B cell | MS4A1, CD79A, CD19 | naive IGHD, TCL1A |
219
+ | Plasma cell, plasmablast | MZB1, JCHAIN, XBP1, TNFRSF17 | MS4A1 low; plasmablasts express MKI67 |
220
+ | CD14 monocyte | CD14, LYZ, S100A8, S100A9, FCN1 | |
221
+ | CD16 monocyte | FCGR3A, MS4A7, CDKN1C | FCGR3A also on NK |
222
+ | Conventional DC | FCER1A, CD1C, CLEC10A; CLEC9A (cDC1) | FCER1A also on basophils |
223
+ | Plasmacytoid DC | LILRA4, IL3RA, CLEC4C | GZMB shared with NK; MZB1 also high |
224
+ | Basophil, mast cell | CPA3, HDC, MS4A2; TPSAB1, KIT (mast) | |
225
+ | Neutrophil | CSF3R, FCGR3B, CXCR2 | low counts; rare after density gradients |
226
+ | Platelet, megakaryocyte | PPBP, PF4 | often attached to monocytes |
227
+ | Erythroid | HBB, HBA1 | frequent ambient contamination |
228
+ | HSPC | CD34, PRSS57, SPINK2, CYTL1, SOX4 | rare in blood; primed subsets add GATA2, KLF1 or CPA3 |
229
+ | Cycling (a state) | MKI67, TOP2A | label as a state, not a type |
230
+ | Immune, macrophage | PTPRC; C1QA, CD68 (macrophage) | |
231
+ | Epithelium, gut | EPCAM, KRT8, KRT18; LGR5, OLFM4 (stem) | goblet MUC2; colonocyte CA1 |
232
+ | Fibroblast | PDGFRA, COL1A1, DCN | subtypes vary by tissue, e.g. APOD or CCN2 (CTGF) high; `^CCN` is in the default HVG blacklist, markers still test it |
233
+ | Endothelium | PECAM1, VWF, CDH5 | |
234
+ | Smooth muscle, pericyte | ACTA2, MYH11, DES; RGS5 (pericyte) | ACTA2 also on myofibroblasts |
235
+ | Keratinocyte, melanocyte | KRT5, KRT14 (basal), KRT10 (suprabasal); PMEL, MLANA (melanocyte) | |
236
+ | Neuron | RBFOX3, SNAP25; SLC17A7 (excitatory); GAD1, GAD2 (inhibitory) | |
237
+ | Astrocyte, oligodendrocyte | GFAP, AQP4; MBP, PLP1, MOG (oligodendrocyte) | |
238
+ | OPC, microglia | PDGFRA, CSPG4 (OPC); CSF1R, P2RY12 (microglia) | CSF1R also on macrophages |
239
+
240
+ ## Parameters that matter
241
+
242
+ | Parameter | Default | Change when |
243
+ |---|---|---|
244
+ | `min_score` (`get_markers`) | `0.25` | `-1` to see negatives and weak genes; raise for a short list |
245
+ | `min_frac_exp` (`get_markers`) | `0.2` | `-1` with `min_score=-1` for complete panel tables |
246
+ | `features` (`run_marker_search`) | required | `run["feature_universe"]` tests every gene; an HVG ref limits the search |
247
+ | `tmin` (AUCell, WAGGR) | `5` | Lower for short sets; sources with fewer matched targets are dropped |
248
+ | `n_up` (AUCell) | `None` (5% of universe) | Fix it when comparing runs; changes the score scale |
249
+ | `s_genes` / `g2m_genes` | built-in human and mouse lists | Other species or naming schemes |
250
+
251
+ ## Check before moving on
252
+
253
+ - Every named cluster has at least two positive markers with high `frac_exp` and `score` well above
254
+ 1/n_groups, and lineage negatives with low `frac_exp`. The evidence is written next to the label.
255
+ Few markers usually means a split within one lineage: merge, or name a state only with evidence.
256
+ - Clusters with two lineages' markers in the same cells, counts above both parents or a high
257
+ within-lineage doublet rank are `unresolved`. A CD34 program without co-detection is HSPC.
258
+ - Rare types hide in big clusters; check finer resolutions or subcluster the lineage.
259
+ - The label map, marker ref and run ID are recorded. Held-out labels were not read before this.
260
+
261
+ ## Pitfalls
262
+
263
+ - Default `get_markers` filters hide negative markers. `group_id` is a string while Leiden labels
264
+ are integers; convert before joining.
265
+ - Direct calls do not reuse pipeline outputs: `run_marker_search(run["clusters"], ...)` and
266
+ `run_cell_cycle_scoring(...)` write new artifacts and reread counts. Read the run's refs instead.
267
+ - Ambient genes (HBB, LYZ, S100A8, JCHAIN, immunoglobulins) show nonzero `frac_exp` in most
268
+ clusters. Use `score` and `auc`, not detection alone, and leave them out of co-detection checks.
269
+ - Small groups inflate `fold_change` and can pass `min_score` with odd genes; check group size.
270
+ - `ds.cells.insert(..., overwrite=True)` silently replaces a column; use versioned names.
271
+ - `select_cells(include=...)` needs integers for integer cluster artifacts (`TypeError` otherwise).
272
+ - Scores from different feature universes, `n_up` or WAGGR `mode`/`log_transform` are not comparable.
273
+
274
+ ## See also
275
+
276
+ - `clustering-and-embedding.md`: choosing the partition and aligning artifacts to cell IDs.
277
+ - `features-and-graphs.md`: subclustering one lineage before naming its states.
278
+ - `quality-control.md`: doublet scores and QC covariates behind unresolved clusters.
279
+ - `plotting.md` (`dotplot`, `marker_heatmap`); `integration-and-comparisons.md` (label transfer,
280
+ condition comparisons); `pipeline-runs-and-artifacts.md` (run fields, lineage).