---
title: "End to End: From Segmented Exports to a Finished Report"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{End to End: From Segmented Exports to a Finished Report}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
resource_files:
- figures/logo.svg
- figures/logo-dark.svg
---
```{r setup, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE, comment = "#>",
fig.width = 7, fig.height = 4.2, fig.align = "center",
dpi = 96
)
options(width = 84)
library(cellreportR)
```
`vignette("getting-started")` runs the short path on a single plate. This
vignette runs the long one, in the order the package is meant to be used:
```
files on disk -> units -> quality control -> standardization
-> effect sizes at two levels -> sample size -> report
```
Every stage is a separate exported function. Nothing here is a wrapper that
hides a decision, because each of these stages *is* a decision, and the
package's job is to make it visible and auditable afterwards.
All data below is synthetic and generated in-session under an explicit seed.
## 1. Ingest: the design lives in the tree and the file name
Segmentation software writes one export per acquisition, and the design facts
that matter — which compound, which experiment, which plate, which
pre-treatment interval, which exposure level — are usually encoded in the
directory layout and the file name rather than inside the file.
`cr_example_exports()` writes such a tree so the ingest functions can be
demonstrated on real files.
```{r write-tree}
root <- file.path(tempdir(), "cr_vignette_exports")
files <- cr_example_exports(root, seed = 42, n_cells = 60)
basename(files)
```
Three things in those names are not decoration:
* `(split)` marks the second pass of a **two-pass acquisition**. Left
unmerged, that unit contributes twice the cells of its neighbours.
* `(repeat)` marks a **repeated read** of a unit that already has a plain
sibling file.
* `_2.2` *looks* like a second pass of unit `2`, but is a **different physical
unit** on a different plate. It must not merge.
* `(no reagent)` marks the **specificity arm**, where the detection reagent
was left out. Those acquisitions are never samples.
### Column names, path levels, file-name grammar
Three small declarations describe the tree. Each is an object you can print
and check before a single file is read.
```{r spec}
map <- cr_column_map(
exact = c("Event Label" = "cell_id",
"Position X [px]" = "x",
"Position Y [px]" = "y",
"Nuclei - Signal Mean" = "nuclear_signal",
"Target - Signal Mean" = "target_signal"),
prefix = c("^Nuclei - Area" = "area")
)
grammar <- cr_filename_grammar(
tokens = list(interval = "[0-9]+min",
dose = "[0-9]+uM",
mode = "treated|vehicle"),
defaults = list(dose = "0uM", mode = "vehicle"),
core_patterns = c("^[0-9]+min_vehicle$", "^[0-9]+min_[0-9]+uM_treated$"),
prefix_strip = c("CompoundA", "CompoundB")
)
markers <- cr_marker_rules(
merge_unit = "\\(split\\)",
partial_plate = "\\(partial\\)",
omitted_reagent = "\\(no reagent\\)",
reacquisition = "\\(repeat\\)"
)
spec <- cr_path_spec(
levels = c("run", "compound", "experiment", "plate"),
grammar = grammar,
markers = markers
)
spec
```
`core_patterns` is the part worth pausing on. The *absence* of a token is
meaningful here: a name with no exposure token is a vehicle control. A typo in
a token therefore matches nothing, falls through to the default, and silently
reclassifies a treated acquisition as a control — pulling it into the control
denominator of its own batch. The whitelist turns that silent
misclassification into a hard error.
```{r read}
cells <- cr_read_exports(root, column_map = map, spec = spec, progress = FALSE)
dim(cells)
all(cells$parse_ok)
```
Each file now carries the design facts recovered from its path and its
name. The tokens:
```{r tokens}
per_file <- unique(cells[, c("source_file", "compound", "plate", "interval",
"dose", "mode", "replicate", "merge_unit",
"reacquisition", "omitted_reagent")])
per_file$source_file <- sub("\\.csv$", "", per_file$source_file)
per_file[, c("source_file", "interval", "dose", "mode", "replicate")]
```
and the markers, each one a typed flag rather than a string somebody has to
remember:
```{r markers-table}
per_file[, c("source_file", "merge_unit", "reacquisition", "omitted_reagent")]
```
### Files are not units
The specificity arm is split off first, so it cannot be merged into a sample
unit by accident. What remains is resolved into analysis units.
```{r units}
aside <- cells[cells$omitted_reagent, , drop = FALSE]
samples <- cells[!cells$omitted_reagent, , drop = FALSE]
samples <- cr_assign_units(
samples,
key_vars = c("compound", "experiment", "plate", "interval", "dose", "mode"),
rules = cr_merge_rules()
)
attr(samples, "n_units")
```
`cr_unit_map()` is the receipt: which unit came from how many files.
```{r unit-map}
umap <- cr_unit_map(samples)
umap$file <- sub("\\.csv$", "", basename(umap$source_path))
umap[, c("file", "n_cells", "n_files", "merged")]
length(unique(umap$well_id))
```
Nine sample files became seven units. The `(split)` and `(repeat)` files
merged into their siblings; `_2.2` stayed a unit of its own.
```{r dataset}
ds_files <- cr_dataset(samples, unit_var = "well_id")
ds_files
```
## 2. Scaling up
That tree has one unit per arm, which is enough to demonstrate ingest and not
enough to estimate anything. The rest of this vignette uses
`cr_example_screen()`, which produces the same object shape — units carrying
file provenance, a vehicle control inside every batch, a set-aside specificity
arm — at a size that supports estimation.
```{r screen}
screen <- cr_example_screen(seed = 1, n_compounds = 4, n_cells_per_well = 25)
screen
batch <- screen$batch_vars
batch
```
A batch here is the *combination* of four columns, not one column. Everything
downstream that references a control references the control of the unit's own
batch.
```{r design-table}
table(screen$design$compound, screen$design$treatment)
```
## 3. Quality control at the cell level
Sub-threshold debris first, then balancing — in that order, because excluding
first changes the pool that balancing samples from.
```{r qc-cells}
ds <- cr_exclude_small(screen, var = "area", probs = 0.10)
ds <- cr_balance_cells(ds, n_max = 30, seed = 1)
cr_qc_summary(ds)[, c("step", "cells_before", "cells_after", "percent_removed")]
```
The QC log is append-only and travels with the object, so the disposition of
every cell is recoverable at the end from the object itself rather than from
a lab notebook.
## 4. Standardization against each unit's own control
The second experiment ran at a higher raw baseline than the first, and the
second plate higher than the first. Both offsets act on control and treated
units alike, which is exactly what standardizing against the control of a
unit's own batch removes.
```{r batch-ref}
ref <- cr_batch_reference(ds, channel = "target_signal",
control_level = "Vehicle", batch_vars = batch)
head(ref[, c("batch_key", "n_cells", "ctrl_n", "ctrl_median", "has_control")], 4)
```
Inspect this table before standardizing, not after: a batch with very few
control cells, or none at all, is visible here while it is still cheap to
notice.
```{r standardize}
ds <- cr_standardize_batch(ds,
channel = "target_signal",
control_level = "Vehicle",
batch_vars = batch,
method = "log2_fc",
value_to = "log2_fc")
round(summary(ds$cells$log2_fc), 3)
```
`log2_fc` is now a per-cell value on a common scale: zero is the control of
that cell's own batch.
## 5. The unit-level gate, and what it costs
A control-referenced gate is a biological rather than a statistical
criterion: a treated unit should carry more target signal than the vehicle
control acquired in its own batch.
```{r gate}
gate <- cr_qc_gate(ds,
channel = "target_signal",
control_level = "Vehicle",
batch_vars = batch)
gate
```
The gate can only ever drop *low* units. A gate that is too strict therefore
manufactures apparent treatment effects rather than removing artefacts, so
the leverage of the exclusions has to be quantified **before** they are
applied, while both states are still in hand.
```{r gate-impact}
impact <- cr_qc_gate_impact(ds, gate,
value = "log2_fc",
group_var = "treatment",
reference_level = "Vehicle",
by = "compound")
# a narrow view of the same table: every contrast is against "Vehicle",
# so that part of the label carries no information here
data.frame(
compound = impact$compound,
contrast = sub("^Vehicle -> ", "", impact$contrast),
n_excluded = impact$n_units_excluded,
d_with = round(impact$estimate_with_excluded, 2),
d_without = round(impact$estimate_without_excluded, 2),
sign_changed = impact$sign_changed
)
```
`d_with` is the effect estimated with the failing units retained, `d_without`
the same estimate once they are dropped. Read the last column. For the three responsive compounds the gate removes a
few units and moves the estimate in the direction it already pointed. For the
weakest compound the gate removes most of the treated units and *flips the
sign*: there, the gate is doing the selecting, not the compound.
That is a reviewed decision, not an automatic one, and `cr_apply_gate()`
accepts a reviewed exclusion list rather than insisting on the gate's own.
```{r apply-gate}
flips <- unique(impact$compound[impact$sign_changed])
qc_units <- cr_qc_report(ds, gate)
reviewed <- qc_units$well_id[qc_units$verdict == "fail" &
!qc_units$compound %in% flips]
c(gate_excluded = length(gate$excluded), reviewed = length(reviewed))
ds <- cr_apply_gate(ds, gate, units = reviewed)
cr_qc_summary(ds)[, c("step", "cells_before", "cells_after", "percent_removed")]
```
```{r gate-plot}
cr_plot_qc_gate(gate,
statistic = "unit_statistic",
reference = "control_reference",
title = "Each unit against the control of its own batch")
```
## 6. Effect sizes at both levels
The same grid is computed twice from the same cells: once aggregated to the
unit of replication, once on the cells as they are.
```{r effects}
eff_unit <- cr_effect_grid(ds,
value = "log2_fc",
group_var = "treatment",
reference_level = "Vehicle",
by = "compound",
unit = "well_id",
min_n = 3)
eff_cell <- cr_effect_grid(ds,
value = "log2_fc",
group_var = "treatment",
reference_level = "Vehicle",
by = "compound",
min_n = 10)
# shorten the labels of both grids together: cr_compare_levels() joins
# them on the columns they share, so they have to stay consistent
eff_unit$contrast <- sub("^Vehicle -> ", "", eff_unit$contrast)
eff_cell$contrast <- sub("^Vehicle -> ", "", eff_cell$contrast)
eff_unit[, c("compound", "contrast", "n_cmp", "cohens_d",
"cohens_d_ci_low", "cohens_d_ci_high", "p_BH")]
```
`cr_compare_levels()` puts the two side by side and divides the interval
widths.
```{r compare-levels}
cmp <- cr_compare_levels(eff_unit, eff_cell)
head(cmp, 4)
round(attr(cmp, "median_ratio"), 1)
```
The unit-level interval is several times wider than the cell-level interval
for the same estimate. That ratio is not precision gained by measuring more
cells; it is pseudo-replication. Cells within a unit are not independent, so
the cell-level interval understates the uncertainty of the very same number.
Interpretation belongs to the unit level.
Plates are crossed with arms in this design, so the contrast can also be taken
within plate:
```{r blocked}
units <- cr_summarize_wells(ds, channel = "log2_fc", fun = stats::median)
blocked <- cr_blocked_effect(units,
value = "value",
group_var = "treatment",
reference_level = "Vehicle",
block_var = "plate",
by = "compound")
head(blocked[, c("compound", "contrast", "n_blocks", "shift_within_block",
"ci_low", "ci_high", "d_within_block")], 4)
```
```{r screen-figure, fig.height = 4.6}
cr_plot_screen(ds,
value = "log2_fc",
group_var = "treatment",
units = "well_id",
facet_by = "compound",
seed = 1)
```
```{r forest}
cr_plot_forest(eff_unit,
estimate = "cohens_d",
ci_low = "cohens_d_ci_low",
ci_high = "cohens_d_ci_high",
label = "contrast",
facet_by = "compound")
```
## 7. Sample size for the follow-up study
A follow-up powered on the observed point estimate is powered on the luckiest
reading of the current data. `cr_power_grid()` solves the sample size from the
confidence bound *nearer the null* instead, and says so in the `basis` column.
```{r power}
sizes <- cr_power_grid(eff_unit)
sizes[, c("compound", "contrast", "cohens_d", "d_conservative",
"n_observed", "n_conservative")]
```
A contrast whose interval spans the null is not sizable at all, and is
reported as such rather than given a number. The `basis` column says which
of the two happened for every row:
```{r power-basis}
table(sizes$basis)
```
```{r size-figure}
cr_plot_sample_size(sizes, label = "contrast", colour_by = "compound")
```
## 8. The report
`cr_report()` collects the experiment, the quality-control record, the effect
grid, the sample sizes, any supplementary tables and any figures into one
object. Assembling first and formatting later is what keeps a number in the
text tied to the object it came from.
```{r report}
report <- cr_report(
ds,
qc = gate,
effects = eff_unit,
sizes = sizes,
tables = list(units = units, gate_impact = impact, batch_reference = ref),
plots = list(
forest = cr_plot_forest(eff_unit,
estimate = "cohens_d",
ci_low = "cohens_d_ci_low",
ci_high = "cohens_d_ci_high",
label = "contrast",
facet_by = "compound")
),
title = "Four-compound screen",
author = "cellreportR example"
)
names(cr_tables(report))
```
Its `summary` slot is one row per contrast, with the effect grid and the
sample sizes already joined — the overview table of the write-up, derived
rather than retyped:
```{r report-summary}
report$summary[, c("compound", "contrast", "cohens_d", "cohens_d_ci_low",
"cohens_d_ci_high", "n_conservative")]
```
Tables go out as machine-readable files:
```{r export}
out <- file.path(tempdir(), "cr_vignette_out")
dir.create(out, showWarnings = FALSE)
written <- cr_export_tables(cr_tables(report), out, format = "csv",
one_file = FALSE)
basename(written)
```
And every number quoted in prose is emitted from the objects themselves,
rather than transcribed:
```{r macros}
macro_file <- file.path(out, "generated-numbers.tex")
cr_macros(
list(
n_units = nrow(units),
n_cells = cr_n_cells(ds),
top_effect = max(eff_unit$cohens_d),
n_conservative = as.integer(max(sizes$n_conservative, na.rm = TRUE))
),
macro_file
)
cat(readLines(macro_file), sep = "\n")
```
Rendering the assembled object to a document is one further call. It is shown
but not executed here, because it needs a working pandoc and writes a file:
```{r render, eval = FALSE}
cr_render_report(report, output_dir = out, format = "html")
# The same assembled report can also be exported as PDF or Word:
cr_render_report(report, output_dir = out, format = "pdf")
cr_render_report(report, output_dir = out, format = "docx")
```
## What was decided, and where it is recorded
| Decision | Made by | Recorded in |
|---|---|---|
| Which files are one unit | `cr_assign_units()` | `cr_unit_map()`, `provenance` |
| Which objects are debris | `cr_exclude_small()` | `qc_log` |
| How many cells per unit | `cr_balance_cells()` | `qc_log` |
| What a batch is | `batch_vars` | `cr_batch_reference()` |
| Which units are excluded | reviewed list | `qc_log`, `metadata$qc_gate` |
| What the exclusions cost | `cr_qc_gate_impact()` | report table |
| Which level is interpreted | unit | `cr_compare_levels()` |
| What powers the next study | conservative bound | `basis` column |
## Next steps
* `vignette("getting-started")` — the short path on a single plate.
* `vignette("statistical-analysis")` — cell versus unit inference in detail.
* `vignette("dose-response")` — fitting exposure-response curves.
* `vignette("shiny-app")` — the same pipeline in a browser.