pub mod fidelity;
pub mod gaussian;
pub mod groups;
pub mod latent;
pub mod marginals;
pub mod mixture;
pub mod reference;
pub mod sampler;
use gaussian::CopulaCovariance;
use latent::ReferenceLatent;
use log::info;
use mixture::PoissonMixture;
use rayon::prelude::*;
use reference::SparseRef;
pub struct GlobalCopulaFit {
pub gene_names: Vec<Box<str>>,
pub n_genes: usize,
pub hvg_indices: Vec<usize>,
pub mu_hat: Vec<f32>,
pub marginals: Vec<PoissonMixture>,
pub groups: Vec<GroupFit>,
pub active_genes: Vec<usize>,
pub hvg_pos: Vec<Option<u32>>,
pub copula: CopulaCovariance,
pub profile: fidelity::Profile,
pub correlation_genes: Vec<usize>,
}
#[derive(Debug, Clone)]
pub struct GroupFit {
pub name: String,
pub share: f64,
pub marginals: Vec<PoissonMixture>,
pub depth_rho: Vec<f32>,
}
impl GlobalCopulaFit {
pub fn draw_groups(&self, n: usize, seed: u64) -> Vec<usize> {
use rand::distr::Distribution;
use rand::SeedableRng;
if self.groups.len() <= 1 {
return vec![0; n];
}
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let pick = rand::distr::weighted::WeightedIndex::new(self.groups.iter().map(|g| g.share))
.expect("groups with positive shares");
(0..n).map(|_| pick.sample(&mut rng)).collect()
}
pub fn write_effective_nb_size(&self, path: &str, names: &[Box<str>]) -> anyhow::Result<()> {
use legume_numeric::matrix::traits::IoOps;
let r = nalgebra::DMatrix::<f32>::from_iterator(
self.n_genes,
1,
self.marginals.iter().map(|m| {
let cv2 = m.rate_cv2();
if cv2 > 0.0 {
(1.0 / cv2) as f32
} else {
1e9
}
}),
);
let label = ["r_hat".to_string().into_boxed_str()];
r.to_parquet_with_names(path, (Some(names), Some("feature")), Some(&label))
}
pub fn write_fidelity_report(
&self,
triplets: &[(u64, u64, f32)],
n_cells: usize,
reference: &str,
prefix: &str,
) -> anyhow::Result<String> {
let simulated = fidelity::profile(
triplets,
self.n_genes,
n_cells,
&self.active_genes,
&self.correlation_genes,
);
let report = fidelity::FidelityReport {
reference: reference.to_string(),
output: prefix.to_string(),
gene_names: self
.active_genes
.iter()
.map(|&g| self.gene_names[g].to_string())
.collect(),
correlation_genes: self.correlation_genes.clone(),
profiles: fidelity::Paired {
reference: self.profile.clone(),
simulated,
},
};
let path = fidelity::report_path(prefix);
report.write(&path)?;
Ok(path)
}
}
#[derive(clap::Args, Debug, Clone)]
pub struct ReferenceFitArgs {
#[arg(
long,
default_value_t = 2000,
help = "Features (HVGs) in the latent copula",
long_help = "Features (HVGs) in the latent copula, per reference.\n\
Features outside it are drawn independently given depth.\n\
This is used only with a reference."
)]
pub n_hvg: usize,
#[arg(
long,
default_value_t = 100,
help = "Maximum rank of the copula's latent correlation factor",
long_help = "Maximum rank of the factor fitted to the HVGs' latent correlation.\n\
Only factors with positive eigenvalues are kept, so the effective rank\n\
is at most `n_hvg`. This is used only with a reference."
)]
pub copula_rank: usize,
#[arg(
long,
default_value_t = 1e-3,
help = "Smallest share of each HVG's latent variance kept outside the shared factors",
long_help = "Smallest share of each HVG's latent variance kept as its own,\n\
outside the shared factors, so no feature is a copy of another.\n\
This is used only with a reference."
)]
pub regularization: f32,
#[arg(
long,
default_value_t = 0.005,
help = "Smallest share of reference cells an HVG must be detected in",
long_help = "Smallest share of reference cells an HVG must be detected in,\n\
and never fewer than 50 cells. Features seen in fewer cells carry no\n\
correlation the reference can estimate; they are drawn independently\n\
given depth. This is used only with a reference."
)]
pub hvg_min_detection: f32,
#[arg(
long,
default_value_t = false,
help = "Do not couple features to a per-cell depth (reference mode)",
long_help = "Do not couple features to a per-cell depth.\n\
By default each cell draws a depth coordinate (one per modality), and\n\
every feature follows it as strongly as it follows library size in the\n\
reference, so library sizes and detected features vary as the\n\
reference's do. With this flag every cell has the same expected depth.\n\
Each feature keeps the reference's distribution either way.\n\
This is used only with a reference."
)]
pub fixed_depth: bool,
#[arg(
long,
hide = true,
help = "No longer used: reference marginals are Poisson mixtures, with no NB size to bound"
)]
pub r_floor: Option<f32>,
#[arg(
long,
help = "Cell groups the reference is fitted in (1: one population) [topic: 8; trajectory, multiome: 1]",
long_help = "Cell groups the reference is fitted in, found by k-means on the leading\n\
principal components of its HVGs. Each group gets its own count\n\
distributions and depth coupling, and simulated cells are drawn group by\n\
group in the reference's proportions, so a reference of cell types of\n\
very different size keeps its library-size and detected-gene spread.\n\
Each cell's group is written to `{output}.ref_group.gz`.\n\
Groups of fewer than 100 cells join their nearest. 1 fits one\n\
population. The default is 8 for `topic`, and 1 for `trajectory`,\n\
whose tree is meant to be the cells' structure (more groups cross the\n\
reference's cell types with it), and for `multiome`, whose modalities\n\
would each draw a cell's group on their own.\n\
This is used only with a reference."
)]
pub reference_groups: Option<usize>,
#[arg(
long,
help = "Cell labels to fit the reference by, instead of --reference-groups",
long_help = "Cell labels to fit the reference by, instead of --reference-groups: one\n\
label per line in the reference's column order, or `cell<TAB>label`\n\
lines matched by cell name. This is used only with a reference."
)]
pub reference_labels: Option<Box<str>>,
}
pub const DEFAULT_GROUPS: usize = 8;
#[derive(Debug, Clone)]
pub enum GroupSpec {
Cluster(usize),
Labels(Box<str>),
}
impl ReferenceFitArgs {
pub fn global_args<'a>(
&self,
sc: &'a SparseRef,
default_groups: usize,
) -> GlobalCopulaArgs<'a> {
if self.r_floor.is_some() {
log::warn!("--r-floor is no longer used: reference marginals are Poisson mixtures");
}
GlobalCopulaArgs {
sc,
n_hvg: self.n_hvg,
copula_rank: self.copula_rank,
regularization: self.regularization,
hvg_min_detection: self.hvg_min_detection,
groups: match &self.reference_labels {
Some(path) => GroupSpec::Labels(path.clone()),
None => GroupSpec::Cluster(self.reference_groups.unwrap_or(default_groups)),
},
}
}
}
pub struct GlobalCopulaArgs<'a> {
pub sc: &'a SparseRef,
pub n_hvg: usize,
pub copula_rank: usize,
pub regularization: f32,
pub hvg_min_detection: f32,
pub groups: GroupSpec,
}
pub fn fit_global_copula(args: &GlobalCopulaArgs) -> anyhow::Result<GlobalCopulaFit> {
let n_genes = args
.sc
.num_rows()
.ok_or_else(|| anyhow::anyhow!("reference has no num_rows"))?;
let n_cells = args
.sc
.num_columns()
.ok_or_else(|| anyhow::anyhow!("reference has no num_columns"))?;
if n_cells < 2 {
anyhow::bail!(
"reference has only {} cells; need ≥2 to fit a copula",
n_cells
);
}
let gene_names = args.sc.row_names()?;
info!("reference: {} genes × {} cells", n_genes, n_cells);
let cells: Vec<usize> = (0..n_cells).collect();
let (stats, libs, mat) = reference::per_gene_stats(args.sc, &cells, n_genes)?;
let mu_hat: Vec<f32> = stats.iter().map(|s| s.mu as f32).collect();
let active_genes: Vec<usize> = (0..n_genes).filter(|&g| stats[g].nnz > 0).collect();
let hvg_indices = reference::select_hvg(&stats, n_cells, args.n_hvg, args.hvg_min_detection);
anyhow::ensure!(
!hvg_indices.is_empty(),
"no gene is detected in {:.1}% (and 50) of the {} reference cells; lower --hvg-min-detection",
100.0 * args.hvg_min_detection,
n_cells
);
info!(
"active genes (detected in the reference): {}/{} ({:.1}%)",
active_genes.len(),
n_genes,
100.0 * active_genes.len() as f32 / n_genes as f32
);
let hist = latent::gene_histograms(&mat, n_cells, &active_genes);
let marginals: Vec<PoissonMixture> = hist
.par_iter()
.map(|h| {
h.as_deref()
.map_or_else(PoissonMixture::zero, PoissonMixture::fit)
})
.collect();
let cell_groups = match &args.groups {
GroupSpec::Cluster(k) => groups::CellGroups::cluster(&mat, &libs, &hvg_indices, *k),
GroupSpec::Labels(path) => groups::CellGroups::from_labels(path, args.sc)?,
};
let (group_fits, hvg_residual) = fit_groups(
&mat,
&libs,
&cell_groups,
&active_genes,
&hvg_indices,
&marginals,
&hist,
);
drop(hist);
let zero: Vec<f32> = active_genes
.iter()
.map(|&g| 1.0 - stats[g].nnz as f32 / n_cells as f32)
.collect();
let correlation_genes = fidelity::correlation_genes(&active_genes, &hvg_indices, &zero);
let profile = fidelity::profile(&mat, n_genes, n_cells, &active_genes, &correlation_genes);
drop(mat);
let copula =
CopulaCovariance::from_correlation(&hvg_residual, args.copula_rank, args.regularization)?;
let mean_abs_depth = group_fits
.iter()
.map(|f| {
f.share as f32
* active_genes
.iter()
.map(|&g| f.depth_rho[g].abs())
.sum::<f32>()
/ active_genes.len().max(1) as f32
})
.sum::<f32>();
info!(
"fit global copula: {} HVGs (detected in ≥ {:.1}% of cells), rank {} (cap {}), \
mean residual sd {:.3}; mean |ρ| with depth {:.3}; {} cell group(s)",
hvg_indices.len(),
100.0 * args.hvg_min_detection,
copula.rank(),
args.copula_rank,
copula.ridge_sd.mean(),
mean_abs_depth,
group_fits.len()
);
let mut hvg_pos = vec![None; n_genes];
for (h, &g) in hvg_indices.iter().enumerate() {
hvg_pos[g] = Some(h as u32);
}
Ok(GlobalCopulaFit {
gene_names,
n_genes,
hvg_indices,
mu_hat,
marginals,
groups: group_fits,
active_genes,
hvg_pos,
copula,
profile,
correlation_genes,
})
}
fn fit_groups(
mat: &nalgebra_sparse::CscMatrix<f32>,
libs: &[f64],
cell_groups: &groups::CellGroups,
active: &[usize],
hvg: &[usize],
whole: &[PoissonMixture],
whole_hist: &[Option<Vec<f64>>],
) -> (Vec<GroupFit>, nalgebra::DMatrix<f64>) {
if cell_groups.n_groups() == 1 {
let lat = ReferenceLatent::fit(mat, libs, whole_hist, hvg);
let fit = GroupFit {
name: cell_groups.names[0].clone(),
share: 1.0,
marginals: whole.to_vec(),
depth_rho: lat.depth_rho,
};
return (vec![fit], lat.hvg_residual);
}
let n_total = libs.len().max(1) as f64;
let h = hvg.len();
let mut num = nalgebra::DMatrix::<f64>::zeros(h, h);
let mut den = nalgebra::DMatrix::<f64>::zeros(h, h);
let mut fits = Vec::with_capacity(cell_groups.n_groups());
for (k, cells) in cell_groups.cells().into_iter().enumerate() {
let sub = groups::select_columns(mat, &cells);
let sub_libs: Vec<f64> = cells.iter().map(|&c| libs[c]).collect();
let n = cells.len();
let hist = latent::gene_histograms(&sub, n, active);
let marginals: Vec<PoissonMixture> = hist
.par_iter()
.map(|h| {
h.as_deref()
.map_or_else(PoissonMixture::zero, PoissonMixture::fit)
})
.collect();
let lat = ReferenceLatent::fit(&sub, &sub_libs, &hist, hvg);
let c1 = &lat.hvg_information;
for i in 0..h {
for j in 0..h {
if i != j {
let w = n as f64 * c1[i] * c1[j];
num[(i, j)] += w * lat.hvg_residual[(i, j)];
den[(i, j)] += w;
}
}
}
fits.push(GroupFit {
name: cell_groups.names[k].clone(),
share: n as f64 / n_total,
marginals,
depth_rho: lat.depth_rho,
});
}
let residual = nalgebra::DMatrix::from_fn(h, h, |i, j| {
if i == j {
1.0
} else if den[(i, j)] > 0.0 {
num[(i, j)] / den[(i, j)]
} else {
0.0
}
});
(fits, residual)
}