Different cell boundaries can assign the same detected molecule to
different cells. SegStability describes that correspondence and its
effect on categorical cell-state conclusions. It compares alternative
segmentations of one immutable molecule universe. Each
molecule needs an exact, stable ID, and each segmentation needs a
complete cell census and one explicit status per molecule:
assigned, unassigned, or
ambiguous. An ambiguous molecule is never assigned to a
candidate cell by the package.
The examples below use 24 synthetic molecules. Coordinates and state
labels are illustrative and have no biological interpretation. The
example includes empty cells, an unassigned molecule on each side, and
one ambiguous molecule with two recorded candidates. See
?segmentationExample for its tables.
Before Bioconductor acceptance, install the development package from its public GitHub repository. The commands below are for an interactive R session; this vignette does not run them.
install.packages(c("BiocManager", "remotes"))
BiocManager::install(c("S4Vectors", "BiocStyle"))
remotes::install_github("ramakrishna-p21/SegStability", build_vignettes = TRUE)library(SegStability)
data("segmentationExample", package = "SegStability")
d <- segmentationExample
names(d)
#> [1] "molecules" "cells" "assignments" "ambiguity" "states"
head(d$molecules[, c("molecule_id", "feature_id")])
#> molecule_id feature_id
#> 1 m01 gA
#> 2 m02 gB
#> 3 m03 gC
#> 4 m04 gA
#> 5 m05 gB
#> 6 m06 gCThe constructor matches assignment rows by molecule ID. Their row
order has no identity meaning. cells lists every cell,
including those with no assigned molecules. Candidate evidence is
separate from the assignment’s unresolved status.
make_partition <- function(side) {
a <- d$assignments[[side]]
ambiguity <- d$ambiguity[[side]]
if (!is.null(ambiguity)) {
ambiguity <- S4Vectors::SimpleList(
evidence = S4Vectors::DataFrame(ambiguity$evidence),
candidates = S4Vectors::DataFrame(ambiguity$candidates))
}
SegmentationPartition(d$molecules, side, d$cells[[side]],
a$molecule_id, a$status, a$cell_id,
ambiguity = ambiguity)
}
ss <- SegmentationSet(d$molecules,
list(first = make_partition("first"),
second = make_partition("second")),
specimen_id = "synthetic-example")
segmentationNames(ss)
#> [1] "first" "second"
segmentationInfo(ss)[, c("name", "n_cells", "n_molecules")]
#> DataFrame with 2 rows and 3 columns
#> name n_cells n_molecules
#> <character> <integer> <integer>
#> 1 first 6 24
#> 2 second 7 24[[ retrieves a named, universe-bound partition.
assignmentData() returns the original external molecule
IDs, semantic statuses, and typed cell IDs in shared-universe order.
Missing cell IDs for unresolved assignments are retained. Coordinate
declarations can be read from either set or comparison summaries;
reading a declaration does not certify spatial compatibility.
cellCensus(ss[["first"]])
#> DataFrame with 6 rows and 1 column
#> cell_id
#> <character>
#> 1 a1
#> 2 a2
#> 3 a3
#> 4 a4
#> 5 a5
#> 6 a0
tail(assignmentData(ss, "first"), 4)
#> DataFrame with 4 rows and 3 columns
#> molecule_id status cell_id
#> <character> <character> <character>
#> [21,] m21 assigned a1
#> [22,] m22 unassigned NA
#> [23,] m23 ambiguous NA
#> [24,] m24 unassigned NA
summary(ss)$coordinate_info
#> List of length 0cmp <- compareSegmentations(ss, "first", "second")
summary(cmp)$coordinate_info
#> List of length 0
edge <- correspondenceEdges(cmp)
edge_table <- data.frame(
first = cellSummary(cmp, "first")$cell_id[S4Vectors::from(edge)],
second = cellSummary(cmp, "second")$cell_id[S4Vectors::to(edge)],
weight = S4Vectors::mcols(edge)$weight)
edge_table
#> first second weight
#> 1 a1 b1 4
#> 2 a2 b2 3
#> 3 a2 b3 3
#> 4 a3 b4 3
#> 5 a4 b4 3
#> 6 a5 b5 2
#> 7 a5 b6 2
componentSummary(cmp)[, c("component_id", "component_class")]
#> DataFrame with 6 rows and 2 columns
#> component_id component_class
#> <integer> <character>
#> 1 1 one_to_one
#> 2 2 split
#> 3 3 merge
#> 4 4 split
#> 5 5 unmatched
#> 6 6 unmatchedAn edge weight counts molecules uniquely assigned to its two endpoint
cells. For a first-side cell, \(N_X\)
is its complete uniquely assigned molecule count; for a second-side
cell, the corresponding count is \(N_A\). Endpoint fractions divide the edge
weight by that endpoint’s own denominator. The denominator is
independent of how many molecules the other segmentation assigned. For
example, the first-side a1 has five assigned molecules,
including m21, which is unassigned on the second side; its
edge to b1 therefore has fraction 4/5.
Dominance uses the complete graph. Exact maximum ties remain ties; a
reciprocal anchor requires one unique maximum at each endpoint.
Connected components include empty and unmatched census cells. Component
labels such as split and merge describe graph
shape, rather than biological division or a preferred segmentation.
cellSummary(cmp, "first")[, c("cell_id", "assigned_mass", "matched_mass",
"unmatched_mass", "dominance_count")]
#> DataFrame with 6 rows and 5 columns
#> cell_id assigned_mass matched_mass unmatched_mass dominance_count
#> <character> <numeric> <numeric> <numeric> <integer>
#> 1 a1 5 4 1 1
#> 2 a2 6 6 0 2
#> 3 a3 3 3 0 1
#> 4 a4 3 3 0 1
#> 5 a5 4 4 0 2
#> 6 a0 0 0 0 0
assignmentSummary(cmp)[assignmentSummary(cmp)$n_molecules > 0, ]
#> DataFrame with 5 rows and 3 columns
#> status_1 status_2 n_molecules
#> <character> <character> <numeric>
#> 1 assigned assigned 20
#> 2 assigned unassigned 1
#> 3 unassigned assigned 1
#> 4 unassigned unassigned 1
#> 5 ambiguous assigned 1Cell states are supplied separately and keyed by census cell ID. Comparable state names should use one shared vocabulary. A missing label is unresolved, and never counted as disagreement. The example has 20 shared molecules, of which 18 have labels on both endpoint cells: 15 share a label and three differ. The original first and second assigned denominators are 21 and 22.
state <- stateStability(cmp, d$states)
state$global[, c("matched_mass", "resolved_mass", "same_state_mass",
"different_state_mass", "unresolved_label_mass")]
#> DataFrame with 1 row and 5 columns
#> matched_mass resolved_mass same_state_mass different_state_mass
#> <numeric> <numeric> <numeric> <numeric>
#> 1 20 18 15 3
#> unresolved_label_mass
#> <numeric>
#> 1 2supportSensitivity() selects edges in each requested
direction when their weight divided by the original
assigned mass of that direction’s endpoint meets the threshold. The
directions are separate calculations. Filtering does not change the
primary graph, its dominance, components, census, or denominators.
support <- supportSensitivity(cmp, thresholds = c(0, 0.6),
states = d$states)
as.data.frame(support[, c("direction", "threshold", "original_matched_mass",
"retained_mass", "excluded_matched_mass",
"unmatched_mass", "assigned_mass")])
#> direction threshold original_matched_mass retained_mass excluded_matched_mass
#> 1 first 0.0 20 20 0
#> 2 first 0.6 20 10 10
#> 3 second 0.0 20 20 0
#> 4 second 0.6 20 14 6
#> unmatched_mass assigned_mass
#> 1 1 21
#> 2 1 21
#> 3 2 22
#> 4 2 22retained_mass counts shared molecules on selected edges;
excluded_matched_mass counts originally shared molecules on
filtered edges. unmatched_mass is the original assigned
mass with no partner in the other segmentation. These are different
losses. assigned_mass stays at the original denominator.
The cells and components columns hold detailed
tables for each direction and threshold; they retain original census and
component rows. State columns describe the selected edges when labels
were supplied. With states = NULL, state-dependent fields
are NA, rather than zero. Missing labels remain unresolved.
endpoint_unavailable_cells counts cells with a missing
supplied state. total_nonestimable_cells counts
zero-assigned cells; conditional_nonestimable_cells counts
cells with no selected resolved mass. These fields distinguish an
unavailable ratio from a measured value of zero.
support$cells[[2]][["first"]][, c("cell_id", "assigned_mass",
"selected_retained_mass",
"selected_removed_mass")]
#> DataFrame with 6 rows and 4 columns
#> cell_id assigned_mass selected_retained_mass selected_removed_mass
#> <character> <numeric> <numeric> <numeric>
#> 1 a1 5 4 0
#> 2 a2 6 0 6
#> 3 a3 3 3 0
#> 4 a4 3 3 0
#> 5 a5 4 0 4
#> 6 a0 0 0 0
support$components[[2]][, c("component_id", "component_class",
"original_matched_mass", "selected_retained_mass")]
#> DataFrame with 6 rows and 4 columns
#> component_id component_class original_matched_mass selected_retained_mass
#> <integer> <character> <numeric> <numeric>
#> 1 1 one_to_one 4 4
#> 2 2 split 6 0
#> 3 3 merge 6 6
#> 4 4 split 4 0
#> 5 5 unmatched 0 0
#> 6 6 unmatched 0 0anchorTopology() requires an observation window, polygon
geometry and a qualified common coordinate declaration. Eligible
reciprocal anchors and their fan classes are derived from the complete
correspondence graph. Missing or ineligible geometry remains explicit.
compareGeometry() measures polygon overlap as separate
evidence; its maximum-IoU matching is a derived alignment and does not
replace shared-molecule correspondence. Neither function repairs,
transforms or clips source geometry.
This toy spatial example is run only when the optional
sf package is present. The coordinate declaration here is
supplied for the constructed toy polygons; it should never be inferred
from an unqualified real dataset.
if (requireNamespace("sf", quietly = TRUE)) {
ring <- rbind(c(1, 1), c(2, 1), c(2, 2), c(1, 2), c(1, 1))
polygon <- sf::st_sfc(sf::st_polygon(list(ring)))
geometry <- list(
first = sf::st_sf(cell_id = "a1", geometry = polygon),
second = sf::st_sf(cell_id = "b1", geometry = polygon))
info <- S4Vectors::SimpleList(
units = "micrometres", origin = c(0, 0),
axis_directions = c("right", "up"),
transforms = S4Vectors::SimpleList(),
registration = "synthetic common XY",
specimen_id = "synthetic-example")
window <- c(xmin = 0, xmax = 10, ymin = 0, ymax = 10)
topology <- anchorTopology(cmp, geometry, window, info)
overlap <- compareGeometry(cmp, geometry, info)
head(topology$anchors)
head(overlap$pairs)
}
#> DataFrame with 6 rows and 21 columns
#> cell_index_1 cell_index_2 cell_id_1 cell_id_2 component_id_1
#> <integer> <integer> <character> <character> <integer>
#> 1 1 1 a1 b1 1
#> 2 2 2 a2 b2 2
#> 3 2 3 a2 b3 2
#> 4 3 4 a3 b4 3
#> 5 4 4 a4 b4 3
#> 6 5 5 a5 b5 4
#> component_id_2 edge_index molecule_edge area_1 area_2 intersection_area
#> <integer> <integer> <logical> <numeric> <numeric> <numeric>
#> 1 1 1 TRUE 1 1 1
#> 2 2 2 TRUE NA NA NA
#> 3 2 3 TRUE NA NA NA
#> 4 3 4 TRUE NA NA NA
#> 5 3 5 TRUE NA NA NA
#> 6 4 6 TRUE NA NA NA
#> union_area iou dice overlap_fraction_1 overlap_fraction_2
#> <numeric> <numeric> <numeric> <numeric> <numeric>
#> 1 1 1 1 1 1
#> 2 NA NA NA NA NA
#> 3 NA NA NA NA NA
#> 4 NA NA NA NA NA
#> 5 NA NA NA NA NA
#> 6 NA NA NA NA NA
#> geometry_status geometry_reason positive_overlap pair_index
#> <character> <character> <logical> <integer>
#> 1 eligible Check passed TRUE 1
#> 2 unavailable Census cell geometry.. NA 2
#> 3 unavailable Census cell geometry.. NA 3
#> 4 unavailable Census cell geometry.. NA 4
#> 5 unavailable Census cell geometry.. NA 5
#> 6 unavailable Census cell geometry.. NA 6
#> geometry_matched
#> <logical>
#> 1 TRUE
#> 2 FALSE
#> 3 FALSE
#> 4 FALSE
#> 5 FALSE
#> 6 FALSEWhen the same molecule universe has multiple stochastic segmentation
runs, replicateAgreement() reports all nine
assignment-status combinations and an adjusted Rand index (ARI) on
jointly assigned molecules only. Run-local cell names
are never treated as corresponding identities. This illustration
relabels the synthetic first partition to show the label-invariant
calculation; it is not a claim about variability of a real stochastic
algorithm.
a <- d$assignments$first
a$cell_id <- ifelse(is.na(a$cell_id), NA_character_,
paste0("runA_", a$cell_id))
b <- d$assignments$first
b$cell_id <- ifelse(is.na(b$cell_id), NA_character_,
paste0("runB_", b$cell_id))
runA_cells <- data.frame(cell_id = paste0("runA_", d$cells$first$cell_id))
runB_cells <- data.frame(cell_id = paste0("runB_", d$cells$first$cell_id))
runA <- SegmentationPartition(d$molecules, "runA", runA_cells,
a$molecule_id, a$status, a$cell_id)
runB <- SegmentationPartition(d$molecules, "runB", runB_cells,
b$molecule_id, b$status, b$cell_id)
runs <- SegmentationSet(d$molecules, list(runA = runA, runB = runB),
"synthetic-example")
replicateAgreement(runs, c("runA", "runB"))$pairwise_summary
#> DataFrame with 1 row and 9 columns
#> segmentation_1 segmentation_2 n_molecules n_status_agree n_status_disagree
#> <character> <character> <numeric> <numeric> <numeric>
#> 1 runA runB 24 24 0
#> status_agreement_fraction n_jointly_assigned jointly_assigned_fraction
#> <numeric> <numeric> <numeric>
#> 1 1 21 0.875
#> ari_jointly_assigned
#> <numeric>
#> 1 1fromMoleculeExperiment() extracts retained molecule IDs
from an explicit assay/sample and requires separately supplied
assignments and a complete cell census. It returns arguments for
SegmentationSet(); source row order and coordinates cannot
reconstruct a lost molecule ID. The small example in
?fromMoleculeExperiment runs when that optional package is
installed. geometryFromSpatialFeatureExperiment() extracts
a keyed polygon sidecar with an explicit geometry-to-cell map and
census; see its help page for a runnable guarded example. Geometry does
not establish molecule correspondence or registration on its own.
The ovrlpy
study released saved segmentations of Xenium mouse-brain data. A
development-time replay used
Xenium_V1_FF_Mouse_Brain_MultiSection_1, replicate 1, and
source-defined 500-µm tile (9,9): x and y in [4500,5000) µm. The
immutable ROI universe has 199,448 original molecule IDs. The comparison
is between the original vendor assignment and saved Baysor v0.7.1
output. It exercised compareSegmentations() and
supportSensitivity(); it did not evaluate cell states,
paired geometry, topology, or stochastic replicates.
The small machine-readable record is installed at
extdata/validation/ovrlpy_validation_summary.csv and
documented in ?ovrlpy_validation_summary. The optional
development-time scripts
scripts/prepare_ovrlpy_validation.py and
scripts/reproduce_ovrlpy_validation.R reconstruct the
inputs from the specified public source files and check the reported
results. They are not run while building or checking this package. No
raw molecules, large segmentation outputs, or benchmark working files
are bundled. This evaluated chunk reads only the bundled summary:
path <- system.file("extdata", "validation",
"ovrlpy_validation_summary.csv", package = "SegStability")
validation <- read.csv(path, colClasses = "character")
validation[validation$key %in% c("roi_universe_size", "correspondence_edges",
"shared_mass"), c("key", "value")]
#> key value
#> 33 roi_universe_size 199448
#> 58 correspondence_edges 3984
#> 59 shared_mass 183319Each method’s census includes native-center cells and cells assigned an ROI molecule; a cell crossing the tile has a denominator restricted to its own ROI molecules. The saved outputs differed in upstream preparation, so this is not an isolated boundary-method comparison. Correspondence and directional support describe this qualified input pair; they neither rank segmentation accuracy nor establish biological ground truth.
SegStability does not run a segmentation method, infer missing provenance, choose a preferred segmentation, or establish biological ground truth. State agreement is conditional on supplied labels; geometry overlap is different evidence from molecule sharing. Interpret results within those inputs and their validation reports.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] SegStability_0.99.0 BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] cli_3.6.6 knitr_1.52 rlang_1.3.0
#> [4] xfun_0.62 KernSmooth_2.23-27 DBI_1.3.0
#> [7] otel_0.2.0 generics_0.1.4 sf_1.1-3
#> [10] jsonlite_2.0.0 S4Vectors_0.51.10 buildtools_1.0.0
#> [13] e1071_1.7-17 htmltools_0.5.9 maketools_1.3.2
#> [16] sys_3.4.3 sass_0.4.10 stats4_4.6.1
#> [19] rmarkdown_2.32 grid_4.6.1 classInt_0.4-11
#> [22] evaluate_1.0.5 jquerylib_0.1.4 fastmap_1.2.0
#> [25] yaml_2.3.12 lifecycle_1.0.5 BiocManager_1.30.27
#> [28] compiler_4.6.1 Rcpp_1.1.2 digest_0.6.39
#> [31] R6_2.6.1 class_7.3-24 bslib_0.12.0
#> [34] proxy_0.4-29 tools_4.6.1 units_1.0-1
#> [37] BiocGenerics_0.59.12 cachem_1.1.0