SegStability: correspondence from shared molecules

Introduction

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.

Installation

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         gC

Build and inspect a segmentation set

The 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 0

Correspondence across segmentations

cmp <- 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       unmatched

An 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           1

Categorical cell-state stability

Cell 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                     2

Reading support sensitivity

supportSensitivity() 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            22

retained_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                      0

Optional spatial evidence

anchorTopology() 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            FALSE

Agreement among segmentation runs

When 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                    1

Interoperability and interpretation

fromMoleculeExperiment() 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.

A qualified Xenium application

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 183319

Each 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