Corrects the component of a reference spectra error that lives inside the row space of the spectra themselves, using a fully stained sample and the fact that a marker-negative population must read zero in every channel it is negative for.
This is the complement of correct.unmixing.signatures(), which corrects the
shape of individual rows from detector-space residuals and is structurally
blind to in-span error. Run this second, on the spectra that function
returns, because in unmixed space a row-shape error and a genuine spillover
error are indistinguishable.
The correction runs in two phases. The first estimates the residual spillover
matrix pair by pair, from a robust line through the events that are negative
for the target channel: their apparent abundance there is spillover from the
source, so its slope against source abundance is the residual coefficient.
Truncating the target from above shrinks that slope toward zero when the two
markers are co-expressed, which is the safe direction. A negative
correlation between a source's abundance and a channel's corrected value has
no biological explanation the way co-expression or coverage disagreement
can, so it is checked directly on the source's own positive population
against the channel's negative boundary (the unstained population's own
measured negative tail, not a mirror of the positive side) and treated as
unambiguous: trust falls as the fraction of that population left past the
boundary grows, reaching a hard rejection at max.hypernegative.frac
regardless of whether this coefficient introduced the crossing or merely
failed to fix one already there. Trust damping only controls what a given
iteration adds; a pair that is only ever weakly accepted would otherwise
settle at whatever that weak, repeated acceptance adds up to, never revised
down for staying weak. coefficient.decay pulls every off-diagonal pair
back toward zero in proportion to how little trust it currently earns, so a
coefficient later supports erodes rather than persists. The second phase does
not back-solve a spectrum out of the matrix. It uses the matrix only to
define clean populations, then re-measures each fluorophore's signature
directly from background subtracted raw data with extract.raw.signature(),
so the result is a physical spectrum rather than whichever combination of the
existing rows happens to reproduce the required compensation.
Autofluorescence is fitted jointly with the panel from a multi-component
basis (bg.mode = "af.deconv"), not represented by a single row. In a real
sample the background a single row leaves behind tracks cell type, cell type
determines marker expression, and the leftover is then indistinguishable from
spillover by any channel-against-channel fit.
When a distinct, strongly autofluorescent population is not well spanned by
that shared low-rank basis, bg.mode = "per.cell" instead assigns each
event a single discrete spectrum from an af.spectra library
(get.af.spectra()) and subtracts only that one. This is a more expressive
background model, but its assignment criterion favours explaining away
signal, so an event genuinely positive for a panel dye can be misassigned to
a competing library row; see af.assignment in the return value and
max.hotspot, which drops library rows that are themselves inseparable from
a panel fluorophore before assignment runs.
Every row update must pass an acceptance stack; a fluorophore that fails any gate keeps its starting spectrum.
Usage
fix.my.unmix(
spectra,
unstained.sample,
fully.stained.sample,
flow.control,
asp,
variants = NULL,
af.name = "AF",
af.basis = NULL,
af.n.pc = "auto",
af.spectra = NULL,
bg.mode = c("af.deconv", "af.row", "global.mean", "none", "per.cell"),
large.gate = TRUE,
scatter.gate = TRUE,
landmark.quantile = NULL,
max.iter = 20L,
downsample = 20000,
downsample.background.frac = 0.3,
downsample.min.stratum = 2000L,
unstained.threshold = 0.99,
unstained.margin = 1.3,
spread.kappa = 2,
envelope.quantiles = c(0.05, 0.5),
min.negative.frac = 0.1,
max.disagreement = 0.5,
min.hypernegative.frac = 0.01,
max.hypernegative.frac = 0.05,
min.hypernegative.events = 200L,
leakage.prior = TRUE,
span.fraction = 0.6,
min.negative.events = 200L,
min.bin.negative = 25L,
min.span = 5,
min.rise = 1,
n.levels = 60L,
min.bin.events = 50L,
n.levels.pair = 10L,
multivariate = TRUE,
ridge = 1e-06,
max.truncated.events = 20000L,
max.mask.passes = 3L,
estimator = c("truncated", "envelope"),
source.dominant = TRUE,
spread.addback = FALSE,
anchor.weight = 1,
max.coefficient = 0.5,
convergence.threshold = 0.01,
convergence.quantile = 0.95,
coefficient.decay = 0.05,
update.spectra = TRUE,
step = 1,
intercept = TRUE,
min.explained = 0.8,
max.explained = 1.2,
max.resid = 0.03,
max.intercept = 0.03,
min.bg.align = -0.9,
gate.on.bias = FALSE,
min.impact.ratio = 2,
max.angle = 15,
max.clamp.frac = 0.15,
max.anchor = 0.1,
max.vif = 500,
max.condition.increase = 1.05,
peak.shift.min.rel = 0.7,
max.hotspot = 5,
leakage.margin = 0.05,
n.threads = 1L,
figures = TRUE,
save = TRUE,
verbose = TRUE,
keep.history = FALSE
)Arguments
- spectra
The spectral matrix, fluorophores x detectors, L-infinity normalised. Ideally the output of
correct.unmixing.signatures().- unstained.sample
File path and name for a raw unstained sample, acquired the same day and matching the autofluorescence of the fully stained sample.
- fully.stained.sample
File path and name for a raw fully stained sample.
- flow.control
The flow.control list.
- asp
The AutoSpectral parameter list.
- variants
The variant list returned by
get.spectral.variants(). Onlyvariants$spillover.spreadis used, to set abundance-dependent positivity boundaries and correct the envelope for the widening of the negative population. The negative boundary used to bound how far a proposed coefficient may push events into hypernegative territory is measured directly from the unstained sample's own negative tail inside this function (seemax.hypernegative.frac), not derived fromvariants. IfvariantsisNULL, positivity boundaries are flat and the correction is biased at the bright end.- af.name
Character or
NULL, the name of an autofluorescence row inspectra. It is never treated as a panel fluorophore and never corrected. Default"AF".- af.basis
Optional matrix (components x detectors) from
get.af.basis(). WhenNULLandbg.mode = "af.deconv", it is built from the unstained sample. DefaultNULL.- af.n.pc
Integer or
"auto", passed toget.af.basis(). Default"auto".- af.spectra
Optional matrix (n.af x detectors), a library of candidate autofluorescence spectra from
get.af.spectra(). Required, with at least two rows, whenbg.mode = "per.cell"; unused otherwise.- bg.mode
Character.
"af.deconv"(default) fits a multi-component autofluorescence basis jointly with the panel;"af.row"uses the singleaf.namerow already inspectra;"global.mean"uses the mean unstained spectrum as a single background row;"per.cell"assigns each event the single best-matching spectrum from theaf.spectralibrary and subtracts only that one, which can follow a distinct, strongly autofluorescent population (e.g. alveolar macrophages) that a shared low-rank basis averages away;"none"fits no background. The condition-number and leakage checks in the signature phase, and the frozen-fluorophore check, still run againstaf.spectra's row 1 (its population mean) under"per.cell"— only the background actually subtracted from each event differs.- large.gate
Logical, whether to use a large scatter gate. Default
TRUE.- scatter.gate
Logical, whether to gate on scatter at all before fitting.
FALSEkeeps every event in bothunstained.sampleandfully.stained.sample, ignoringlarge.gateentirely. Turn off when a population the correction needs – a large, highly autofluorescent cell type such as alveolar macrophages, say – sits far enough outside the main scatter population that evenlarge.gate's stretch does not reach it, and no single gate shape can be expected to enclose everyone the correction needs. DefaultTRUE.- landmark.quantile
Numeric in
(0, 1)orNULL, a lighter-weight alternative to turning gating off outright: events above this quantile on either scatter parameter are kept in addition to whatever the gate polygon already keeps, rather than replacing it. The threshold is computed once fromunstained.sampleand reused unchanged forfully.stained.sample, so the same large-cell population is added to both the autofluorescence basis and the correction it feeds, rather than one being widened without the other. Ignored whenscatter.gate = FALSE.NULL(default) applies no landmark rescue.- max.iter
Integer, maximum spillover-matrix iterations. Default
20.- downsample
Logical or numeric.
FALSEdisables downsampling; a numeric gives the number of events to use. Values above the event count are reduced to it by a stratified sample over each event's dominant fluorophore under the starting spectra, so a dim or rare dye's own positive population is not thinned at the same rate as the background bulk. Default20000.- downsample.background.frac
Numeric in (0, 1), the share of
downsamplereserved for events dominant for nothing. Default0.3.- downsample.min.stratum
Integer, the floor below which a fluorophore's own positive population is kept whole by the stratified downsample rather than thinned further. Default
2000.- unstained.threshold
Numeric in (0, 1), the percentile of the unstained control defining positivity. Default
0.99.- unstained.margin
Numeric, multiplier applied to that threshold. Default
1.3.- spread.kappa
Numeric, how many spillover-spread standard deviations above the flat threshold still count as negative. Default
2.- envelope.quantiles
Numeric pair. The first is the envelope the slope is measured on; the second is a comparison quantile whose disagreement with the first reports co-expression. Default
c( 0.05, 0.5 ).- min.negative.frac
Numeric, the smallest fraction of target-negative events the brightest source bins may contain before the pair is declared unidentifiable and left alone. Default
0.10.- max.disagreement
Numeric, envelope-versus-median slope disagreement above which the coefficient's trust weight is reduced. Default
0.5.- min.hypernegative.frac
Numeric, the fraction of a source's own positive population tolerated past the channel's negative boundary before trust begins to fall at all. The boundary is the channel's own spread-scaled negative threshold, anchored to the unstained population's own negative tail (measured directly, each iteration, alongside the positive threshold) rather than mirrored from the positive boundary about zero – autofluorescence is non-negative, so it inflates the positive tail of an unstained control without inflating the negative tail correspondingly, making the mirrored assumption systematically too loose. Noise and incorrectly-unmixed, AF-skewed events always account for some small tail here, and a fraction at or below this is not itself evidence that a coefficient is wrong. Default
0.01.- max.hypernegative.frac
Numeric, the fraction at which trust reaches exactly zero. Between
min.hypernegative.fracand this cap trust falls off linearly; unlikemax.disagreement, which only damps a coefficient in proportion to how far past a soft limit it sits, this reaches a hard rejection rather than an asymptote, because a negative correlation between a source's abundance and a channel's corrected value has no biological explanation and so is not owed an indefinitely soft penalty. The fraction checked is absolute, not a delta against whatever already sat past the boundary before this coefficient: a pre-existing hypernegative artefact is evidence of an incorrect coefficient somewhere just as much as a newly-introduced one, even when a different pair's coefficient is what put it there.coefficient.log'shypernegative.baseandhypernegative.newcolumns separate the two for diagnosis without treating either as free. Default0.05.- min.hypernegative.events
Integer, the fewest events a source's own positive population may contain before its hypernegative fraction is estimated at all. Below this,
hypernegative.base,hypernegative.afterandhypernegative.neware leftNAincoefficient.logand onlymax.disagreementand the prior variance bound the pair's trust. Default200L.- leakage.prior
Logical, whether to weight coefficients by the spillover a plausible spectral error could produce, from
get.variant.leakage.prior(). WhenFALSE, or when no variants are supplied, the estimate supplies its own prior variance, which lets a large spurious coefficient justify itself. DefaultTRUE.- span.fraction
Numeric in (0, 1], passed to
get.variant.leakage.prior(). The fraction of real spectral error the variant family is expected to span; lower values give corrections more freedom outside the observed variant directions. Default0.6.- min.negative.events
Integer, minimum events for a pair fit. Default
200.- min.bin.negative
Integer, minimum target-negative events an abundance bin must contain before its envelope quantile is used. Default
25.- min.span
Numeric, minimum source abundance span in units of that fluorophore's own threshold. Default
5.- min.rise
Numeric, the fitted rise across the source abundance span, in standard deviations of the target's negative population, below which a coefficient is treated as unresolvable and set to zero. Default
1.- n.levels
Integer, the maximum number of abundance bins for the signature fit. Must exceed the panel size by at least three when
multivariateisTRUE. Default60.- min.bin.events
Integer, the fewest events an abundance bin may contain, passed to
extract.raw.signature(). Default50.- n.levels.pair
Integer, abundance bins for the pair estimator. Default
10.- multivariate
Logical, passed to
extract.raw.signature(), whether every active abundance is fitted jointly rather than the nuisance rows being subtracted beforehand. DefaultTRUE.- ridge
Numeric, ridge penalty for that joint fit. Default
1e-6.- max.truncated.events
Integer, cap on the events used for the robust pair fit. Everything above the source threshold is kept and the negative bulk below it is subsampled, since the bulk sits at the origin and carries no leverage. Default
20000.- max.mask.passes
Integer, how many times the target-negative selection is recomputed with the current spillover estimate removed. Deciding negativity on the raw target caps the fit's source range at the target threshold divided by the coefficient, so the estimator loses its leverage precisely as the coefficient it is measuring grows. Default
3.- estimator
Character, which pair estimator supplies the coefficients.
"truncated"(default) is a robust line through the target-negative events;"envelope"is the binned lower envelope.- source.dominant
Logical, whether source-positive events dominated by another fluorophore are dropped from the pair fit. Their apparent source abundance is that other fluorophore's brightness read through a spectral error, so binning on it returns that error as this pair's spillover. Default
TRUE.- spread.addback
Logical, whether the measured spillover spread is added back to the envelope before its slope is taken. Default
FALSE.- anchor.weight
Numeric, the weight ceiling for the zero-abundance anchor bin, in multiples of the best positive bin. Default
1.- max.coefficient
Numeric, the largest residual spillover coefficient accepted. Rows are L-infinity normalised, so a coefficient this large means one row is wrong by that fraction of another row's whole spectrum, which violates the small-error premise the linearisation rests on. Default
0.2.- convergence.threshold
Numeric, residual spillover coefficient at which iteration stops. Default
0.01.- convergence.quantile
Numeric, the quantile of the off-diagonal coefficients the convergence test uses, so that one pathological pair cannot define convergence. Default
0.95.- coefficient.decay
Numeric in
[0, 1), the maximum fraction by which an off-diagonal spillover coefficient is pulled toward zero on a single iteration, scaled by1 - trustfor that pair. A pair earning strong trust this iteration barely decays; a pair clearing the acceptance gates only weakly, repeatedly, decays at close to the full rate every time, since repeated low confidence is itself the signal that a coefficient is not well identified, not evidence for holding it steady. Gating decay ontrust == 0alone catches only a pair that fails the hard gates outright, missing the much more common case of a pair that clears them every iteration but is never confidently estimated. Default0.05.- update.spectra
Logical, whether to run the raw-space signature phase. When
FALSE, only the spillover and compensation matrices are returned. DefaultTRUE.- step
Numeric, the fraction of each accepted signature change applied. Default
1.- intercept
Logical, whether the signature fit carries an intercept, which absorbs any constant offset the removal of the other panel rows left behind. Default
TRUE.- min.explained
Numeric, minimum
explained.total, the fraction of the brightest bin's signal the whole signature fit reproduces. Default0.8.- max.explained
Numeric, the maximum of the same quantity. A value above one means the nuisance removal over-subtracted. Default
1.2.- max.resid
Numeric, maximum relative fit residual. Default
0.03.- max.intercept
Numeric, maximum relative intercept. Default
0.03.- min.bg.align
Numeric, minimum cosine between the fitted intercept and the candidate signature. A value near minus one means the fit is trading a background floor against the slope, the event-level signature of an unmodelled background confound. Default
-0.9.- gate.on.bias
Logical, whether
min.impact.ratioactually blocks a candidate, versus being computed and logged only. DefaultFALSE. Evidence from a counterfactual audit (score whether accepting each rejected candidate would have moved the row toward or away from ground truth): of every candidate this gate rejected, 79.9% would have helped if accepted (n=144, two substrates) - 90.7% on one substrate, a coin-flip 47.2% on the other. The gate is net-harmful on the substrate where it discriminates worst and only weakly useful on the other, so it defaults off rather than removed.bias.impact, the quantity it compares against, has no source now thatnull.fitis gone, so the gate is currently always inert regardless of this setting.- min.impact.ratio
Numeric, how many times a proposed step's abundance effect must exceed the effect of the same phase's bias on its own control, measured through the current unmixing operator. Degrees weight every detector alike and every dye alike; this weights each row by the abundances it actually produces, so a large rotation of a dim row is cheap and a small rotation of a bright collinear row is not. Requires
gate.on.bias = TRUEand a source forbias.impact, which no longer exists now thatnull.fitis gone; the parameter is retained but currently inert. Default2.- max.angle
Numeric, maximum angular change of a row in degrees.
- max.clamp.frac
Numeric, maximum fraction of a candidate row's absolute mass that non-negativity clamping may remove. Default
0.15.- max.anchor
Numeric, maximum unremoved background relative to the brightest fitted signal. Default
0.10.- max.vif
Numeric, maximum variance inflation factor for the fluorophore's own abundance within its population. Default
500.- max.condition.increase
Numeric, the factor by which a single accepted row may increase the condition number of the unmixing design. Default
1.05.- peak.shift.min.rel
Numeric, a candidate row whose peak detector differs from the current row is only accepted if the current signature's own value at that new peak channel, relative to the current signature's own peak, is at least this large - a close secondary peak taking over is plausible, a shift to a channel the current signature barely uses is not. Use
Infto never allow a peak shift and0to always allow one. Default0.7.- max.hotspot
Numeric, hotspot scale above which a fluorophore is considered inseparable from the autofluorescence basis and is frozen. Under
bg.mode = "per.cell", also applied to dropaf.spectrarows that are themselves inseparable from a panel fluorophore, before per-event assignment. Default5.- leakage.margin
Numeric, the fraction by which held-out leakage must increase, not merely be numerically larger, before a candidate is refused for it.
.fix.leakage()is a held-out statistic on a dominance population that can be small, and "any increase blocks" was itself producing false positives - candidates blocked on multiple consecutive fits that would demonstrably have helped if accepted. Default0.05.- n.threads
Integer, OpenMP threads for the batched pair estimator (
fix_envelope_truncated_batch_rcpp()) and, whenbg.mode = "per.cell", for the per-event library assignment (assign.af.fluor.fast()). Keep at the default unless this call is not itself running inside another parallel context (e.g. one sample of several undermclapply()), since the two layers of parallelism would otherwise compete for the same cores. Default1L.- figures
Logical, whether to write the spillover heatmap. Default
TRUE.- save
Logical, whether to write the csv outputs. Default
TRUE.- verbose
Logical, controls messaging. Default
TRUE.- keep.history
Logical, whether to retain a per-iteration snapshot of
marker.spillover,trust, the post-decayspillover.next, and that iteration'scoefficient.loginspillover.history, for tracing a specific pair's trajectory – including itshypernegative.base/hypernegative.afterhistory – across the refinement loop rather than inferring it from the final iteration alone.FALSEby default since the snapshots are diagnostic only and addmax.iterlist entries of otherwise-unused matrices to the return value. DefaultFALSE.
Value
A named list:
spectraThe corrected spectra, fluorophores x detectors. Rows that failed a gate are unchanged.
spectra.backsolvedspillover %*% spectra, the algebraic back-solve. Diagnostic only: it reproduces the compensation exactly but is not constrained to be a physical spectrum.spilloverThe estimated residual spillover matrix, fluorophores x fluorophores.
compensationIts inverse.
trustPer-coefficient trust weights used to damp the update.
coefficient.logPer-pair fit diagnostics from the final iteration.
signature.logPer-fluorophore signature statistics and gate outcomes.
convergence.logPer-iteration delta history.
spillover.historyNULLunlesskeep.history = TRUE, in which case a list, one entry per phase-one iteration, each holding that iteration'smarker.spillover(the raw fitted candidates, zero where nothing was accepted),trust,spillover.next(the matrix after that iteration's addition and decay, before the next iteration's row renormalisation), andcoefficient.log(that iteration's per-pair fit diagnostics, includinghypernegative.base/hypernegative.after).unmixed.final,residual.finalThe fully-stained sample's abundance (events x fluorophores, compensated with the final
compensation) and background-subtracted raw-space matrix (events x detectors) - what phase two itself measured signatures from.thresholds.final,threshold.matrix.final,dominant.finalThe final flat positivity threshold (one per fluorophore) and its spread-scaled per-event boundary, and the resulting winning fluorophore per event (
0for none), identical to what phase two used to define each fluorophore's own population.neg.thresholds.final,neg.threshold.matrix.finalThe negative-side equivalents: the flat threshold measured directly from the unstained population's own negative tail (not derived by negating
thresholds.final) and its spread-scaled per-event boundary, identical to what the hypernegative check itself compared events against at convergence. Use these, not-threshold.matrix.final, when auditing this result against a negative boundary externally (e.g.create.biplot(), a cluster-centroid audit) – that mirrored shortcut is exactly the assumption this pair was measured directly to avoid.af.basis,af.hotspot,af.frozenThe autofluorescence basis, its coupling to the panel, and the fluorophores frozen because of it.
af.assignmentNULLunlessbg.mode = "per.cell", in which case a table of how often eachaf.spectrarow was assigned and its mean fitted abundance, over the (possibly downsampled) events actually used.