feat: add phylogenetic analysis CLI command and lazy distance traits
Introduce the `obikphylo` crate to support genome-vs-genome distance matrix computation and phylogenetic tree inference via Neighbor-Joining and UPGMA algorithms. Extend the `obikmer2` CLI with a new `phylo` command that exposes configurable metrics, presence thresholds, and CSV/Newick output options. Refactor `obikindex` to expose trait-based partial aggregation for efficient distance metric finalization without full matrix materialization. Update dependency graphs and remove obsolete storage modules.
This commit is contained in:
Generated
+4
@@ -1677,6 +1677,7 @@ dependencies = [
|
|||||||
"obikindex",
|
"obikindex",
|
||||||
"obikindexer",
|
"obikindexer",
|
||||||
"obikmerge",
|
"obikmerge",
|
||||||
|
"obikphylo",
|
||||||
"obikquery",
|
"obikquery",
|
||||||
"obikrebuild",
|
"obikrebuild",
|
||||||
"obikrope",
|
"obikrope",
|
||||||
@@ -1712,6 +1713,7 @@ dependencies = [
|
|||||||
name = "obikphylo"
|
name = "obikphylo"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kodama",
|
||||||
"memmap2",
|
"memmap2",
|
||||||
"ndarray",
|
"ndarray",
|
||||||
"obicompactvec",
|
"obicompactvec",
|
||||||
@@ -1725,8 +1727,10 @@ dependencies = [
|
|||||||
"obiskbuilder",
|
"obiskbuilder",
|
||||||
"obiskio",
|
"obiskio",
|
||||||
"obisys",
|
"obisys",
|
||||||
|
"petgraph",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rayon",
|
"rayon",
|
||||||
|
"speedytree",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use crate::layer::utils::LAYERNAME_SUFFIX;
|
use crate::layer::utils::LAYERNAME_SUFFIX;
|
||||||
|
|
||||||
use obicompactvec::{PersistentBitMatrix, PersistentIntMatrix};
|
use ndarray::{Array1, Array2};
|
||||||
|
use obicompactvec::{BitPartials, ColumnWeights, CountPartials, PersistentBitMatrix, PersistentIntMatrix};
|
||||||
use obikseq::CanonicalKmer;
|
use obikseq::CanonicalKmer;
|
||||||
|
|
||||||
use crate::index::error::OKIResult;
|
use crate::index::error::OKIResult;
|
||||||
@@ -349,3 +350,79 @@ impl KmerLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── obicompactvec's statistics traits — read-only accessors, same spirit
|
||||||
|
// as `col_weights`/`nonzero_iter` above, just formalised as trait impls so
|
||||||
|
// generic aggregation code (`obikidxcache::IndexCache`'s callers) can rely
|
||||||
|
// on `KmerLayer: ColumnWeights`/`CountPartials`/`BitPartials` rather than
|
||||||
|
// duck-typing matching method names. `CountPartials`/`BitPartials` each
|
||||||
|
// only make sense for the content this layer actually holds — calling the
|
||||||
|
// wrong one panics, same convention as `col()`/`content()` above.
|
||||||
|
|
||||||
|
impl ColumnWeights for KmerLayer {
|
||||||
|
fn col_weights(&self) -> Array1<u64> {
|
||||||
|
self.col_weights()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CountPartials for KmerLayer {
|
||||||
|
fn partial_bray(&self) -> Array2<u64> {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Count { layer, .. } => layer.partial_bray(),
|
||||||
|
KmerLayer::Presence { .. } => panic!("CountPartials::partial_bray() called on a Presence layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("CountPartials::partial_bray() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn partial_euclidean(&self) -> Array2<f64> {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Count { layer, .. } => layer.partial_euclidean(),
|
||||||
|
KmerLayer::Presence { .. } => panic!("CountPartials::partial_euclidean() called on a Presence layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("CountPartials::partial_euclidean() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn partial_threshold_jaccard(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Count { layer, .. } => layer.partial_threshold_jaccard(threshold),
|
||||||
|
KmerLayer::Presence { .. } => panic!("CountPartials::partial_threshold_jaccard() called on a Presence layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("CountPartials::partial_threshold_jaccard() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn partial_relfreq_bray(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Count { layer, .. } => layer.partial_relfreq_bray(global),
|
||||||
|
KmerLayer::Presence { .. } => panic!("CountPartials::partial_relfreq_bray() called on a Presence layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("CountPartials::partial_relfreq_bray() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn partial_relfreq_euclidean(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Count { layer, .. } => layer.partial_relfreq_euclidean(global),
|
||||||
|
KmerLayer::Presence { .. } => panic!("CountPartials::partial_relfreq_euclidean() called on a Presence layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("CountPartials::partial_relfreq_euclidean() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn partial_hellinger(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Count { layer, .. } => layer.partial_hellinger(global),
|
||||||
|
KmerLayer::Presence { .. } => panic!("CountPartials::partial_hellinger() called on a Presence layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("CountPartials::partial_hellinger() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitPartials for KmerLayer {
|
||||||
|
fn partial_jaccard(&self) -> (Array2<u64>, Array2<u64>) {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Presence { layer, .. } => layer.partial_jaccard(),
|
||||||
|
KmerLayer::Count { .. } => panic!("BitPartials::partial_jaccard() called on a Count layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("BitPartials::partial_jaccard() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn partial_hamming(&self) -> Array2<u64> {
|
||||||
|
match self {
|
||||||
|
KmerLayer::Presence { layer, .. } => layer.partial_hamming(),
|
||||||
|
KmerLayer::Count { .. } => panic!("BitPartials::partial_hamming() called on a Count layer"),
|
||||||
|
KmerLayer::Empty { .. } => panic!("BitPartials::partial_hamming() called on an Empty layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ fn count_layer_reports_count_content_and_columnar_storage() {
|
|||||||
set_k(4);
|
set_k(4);
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
write_unitigs(dir.path(), &[b"AAAACGT"]);
|
write_unitigs(dir.path(), &[b"AAAACGT"]);
|
||||||
TypedLayer::<PersistentIntMatrix>::build(dir.pat
|
TypedLayer::<PersistentIntMatrix>::build(
|
||||||
h(), DEFAUL
|
dir.path(),
|
||||||
T_BLOCK_BITS, &Index
|
DEFAULT_BLOCK_BITS,
|
||||||
Mode::Exact, |_| 1)
|
&IndexMode::Exact,
|
||||||
,
|
|_| 1,
|
||||||
).unwrap();
|
).unwrap();
|
||||||
let layer = TypedLayer::<PersistentIntMatrix>::open(dir.path()).unwrap();
|
let layer = TypedLayer::<PersistentIntMatrix>::open(dir.path()).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// use crate::layer::utils::layer_dir;
|
// use crate::layer::utils::layer_dir;
|
||||||
use obicompactvec::{
|
use obicompactvec::{
|
||||||
BinaryMatrix, ColBuilder, MatrixBuilder, PersistentBitMatrix, PersistentBitMatrixBuilder,
|
BinaryMatrix, BitPartials, ColBuilder, CountPartials, MatrixBuilder, PersistentBitMatrix,
|
||||||
PersistentCompactIntMatrixBuilder, PersistentIntMatrix, PersistentSparseBitMatrix,
|
PersistentBitMatrixBuilder, PersistentCompactIntMatrixBuilder, PersistentIntMatrix,
|
||||||
|
PersistentSparseBitMatrix,
|
||||||
};
|
};
|
||||||
|
use ndarray::{Array1, Array2};
|
||||||
use obikseq::CanonicalKmer;
|
use obikseq::CanonicalKmer;
|
||||||
use obiskio::{UnitigFileReader, UnitigFileWriter};
|
use obiskio::{UnitigFileReader, UnitigFileWriter};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -424,6 +426,29 @@ impl TypedLayer<PersistentIntMatrix> {
|
|||||||
) -> Box<dyn Iterator<Item = (usize, usize, u32)> + 'a> {
|
) -> Box<dyn Iterator<Item = (usize, usize, u32)> + 'a> {
|
||||||
self.data.nonzero_iter(slots)
|
self.data.nonzero_iter(slots)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `CountPartials`' required primitives, delegated straight to the
|
||||||
|
/// underlying matrix — see `obicompactvec::CountPartials` for what
|
||||||
|
/// each one means and how its provided finalisation methods
|
||||||
|
/// (`bray_dist_matrix`, `jaccard_dist_matrix`, ...) consume them.
|
||||||
|
pub fn partial_bray(&self) -> Array2<u64> {
|
||||||
|
CountPartials::partial_bray(&self.data)
|
||||||
|
}
|
||||||
|
pub fn partial_euclidean(&self) -> Array2<f64> {
|
||||||
|
CountPartials::partial_euclidean(&self.data)
|
||||||
|
}
|
||||||
|
pub fn partial_threshold_jaccard(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
|
||||||
|
CountPartials::partial_threshold_jaccard(&self.data, threshold)
|
||||||
|
}
|
||||||
|
pub fn partial_relfreq_bray(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
CountPartials::partial_relfreq_bray(&self.data, global)
|
||||||
|
}
|
||||||
|
pub fn partial_relfreq_euclidean(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
CountPartials::partial_relfreq_euclidean(&self.data, global)
|
||||||
|
}
|
||||||
|
pub fn partial_hellinger(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
CountPartials::partial_hellinger(&self.data, global)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode 3 — presence/absence matrix ─────────────────────────────────────────
|
// ── Mode 3 — presence/absence matrix ─────────────────────────────────────────
|
||||||
@@ -487,6 +512,15 @@ impl TypedLayer<PersistentBitMatrix> {
|
|||||||
self.data.nonzero_iter(slots)
|
self.data.nonzero_iter(slots)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `BitPartials`' required primitives, delegated straight to the
|
||||||
|
/// underlying matrix.
|
||||||
|
pub fn partial_jaccard(&self) -> (Array2<u64>, Array2<u64>) {
|
||||||
|
BitPartials::partial_jaccard(&self.data)
|
||||||
|
}
|
||||||
|
pub fn partial_hamming(&self) -> Array2<u64> {
|
||||||
|
BitPartials::partial_hamming(&self.data)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn append_genome_column(
|
pub fn append_genome_column(
|
||||||
layer_dir: &Path,
|
layer_dir: &Path,
|
||||||
value_of: impl Fn(usize) -> bool,
|
value_of: impl Fn(usize) -> bool,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ obikrebuild = { path = "../obikrebuild" }
|
|||||||
obikstats = { path = "../obikstats" }
|
obikstats = { path = "../obikstats" }
|
||||||
obikquery = { path = "../obikquery" }
|
obikquery = { path = "../obikquery" }
|
||||||
obikidxcache = { path = "../obikidxcache" }
|
obikidxcache = { path = "../obikidxcache" }
|
||||||
|
obikphylo = { path = "../obikphylo" }
|
||||||
obikrope = { path = "../obikrope" }
|
obikrope = { path = "../obikrope" }
|
||||||
obifastwrite = { path = "../obifastwrite" }
|
obifastwrite = { path = "../obifastwrite" }
|
||||||
obiskbuilder = { path = "../obiskbuilder" }
|
obiskbuilder = { path = "../obiskbuilder" }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod index;
|
|||||||
pub mod merge;
|
pub mod merge;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
mod predicate;
|
mod predicate;
|
||||||
|
pub mod phylo;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod superkmer;
|
pub mod superkmer;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Args;
|
||||||
|
use obikphylo::DistanceMetric;
|
||||||
|
|
||||||
|
#[derive(clap::ValueEnum, Clone, Copy, Debug)]
|
||||||
|
pub enum MetricArg {
|
||||||
|
Jaccard,
|
||||||
|
Mash,
|
||||||
|
Hamming,
|
||||||
|
BrayCurtis,
|
||||||
|
#[value(name = "relfreq-bray-curtis")]
|
||||||
|
RelfreqBrayCurtis,
|
||||||
|
Euclidean,
|
||||||
|
#[value(name = "relfreq-euclidean")]
|
||||||
|
RelfreqEuclidean,
|
||||||
|
Hellinger,
|
||||||
|
#[value(name = "hellinger-euclidean")]
|
||||||
|
HellingerEuclidean,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MetricArg> for DistanceMetric {
|
||||||
|
fn from(m: MetricArg) -> Self {
|
||||||
|
match m {
|
||||||
|
MetricArg::Jaccard => DistanceMetric::Jaccard,
|
||||||
|
MetricArg::Mash => DistanceMetric::Mash,
|
||||||
|
MetricArg::Hamming => DistanceMetric::Hamming,
|
||||||
|
MetricArg::BrayCurtis => DistanceMetric::BrayCurtis,
|
||||||
|
MetricArg::RelfreqBrayCurtis => DistanceMetric::RelfreqBrayCurtis,
|
||||||
|
MetricArg::Euclidean => DistanceMetric::Euclidean,
|
||||||
|
MetricArg::RelfreqEuclidean => DistanceMetric::RelfreqEuclidean,
|
||||||
|
MetricArg::Hellinger => DistanceMetric::Hellinger,
|
||||||
|
MetricArg::HellingerEuclidean => DistanceMetric::HellingerEuclidean,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Partial transfer of `obikmer`'s `phylo` command: the plain distance-metric
|
||||||
|
/// path only (`--metric`/NJ/UPGMA) — everything sibling-annex-based
|
||||||
|
/// (`--sibling-annex`, `--snp`, `--sankoff`, `--tnt`/`--phyg`/`--iqtree`, ...)
|
||||||
|
/// stays in `obikmer` until `obikphylo::siblings` is reconnected (see the
|
||||||
|
/// project memory on this).
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct PhyloArgs {
|
||||||
|
/// Index directory
|
||||||
|
pub index: PathBuf,
|
||||||
|
|
||||||
|
/// Distance metric to compute
|
||||||
|
#[arg(long, value_enum, default_value = "jaccard")]
|
||||||
|
pub metric: MetricArg,
|
||||||
|
|
||||||
|
/// Minimum count to consider a kmer present when computing Jaccard on count indexes
|
||||||
|
#[arg(long, default_value = "1")]
|
||||||
|
pub presence_threshold: u32,
|
||||||
|
|
||||||
|
/// Also output the shared-kmer count matrix (CSV)
|
||||||
|
#[arg(long)]
|
||||||
|
pub shared_kmers: bool,
|
||||||
|
|
||||||
|
/// Compute and write a Neighbor-Joining tree (Newick)
|
||||||
|
#[arg(long)]
|
||||||
|
pub nj: bool,
|
||||||
|
|
||||||
|
/// Compute and write a UPGMA tree (Newick)
|
||||||
|
#[arg(long)]
|
||||||
|
pub upgma: bool,
|
||||||
|
|
||||||
|
/// Output prefix: <prefix>_dist.csv, <prefix>_shared.csv, <prefix>_nj.nwk,
|
||||||
|
/// <prefix>_upgma.nwk. If omitted, the distance matrix is written to stdout.
|
||||||
|
#[arg(short, long)]
|
||||||
|
pub output: Option<PathBuf>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
mod args;
|
||||||
|
|
||||||
|
use std::io::{self, BufWriter, Write};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use obikidxcache::index_cache::IndexCache;
|
||||||
|
use obikindex::KmerIndex;
|
||||||
|
use obikphylo::{Metrics, neighbor_joining_newick, upgma_newick};
|
||||||
|
use obisys::{Reporter, Stage};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub use args::PhyloArgs;
|
||||||
|
|
||||||
|
pub fn run(args: PhyloArgs) {
|
||||||
|
let idx = Arc::new(KmerIndex::open(&args.index).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error opening index: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}));
|
||||||
|
|
||||||
|
let labels: Vec<String> = idx
|
||||||
|
.meta()
|
||||||
|
.genomes()
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("error reading index metadata: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
})
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.label.clone())
|
||||||
|
.collect();
|
||||||
|
let n = labels.len();
|
||||||
|
|
||||||
|
let mut rep = Reporter::new();
|
||||||
|
|
||||||
|
info!("computing {:?} distances for {} genome(s)", args.metric, n);
|
||||||
|
|
||||||
|
// Every partition/layer this needs is opened once, up front, and
|
||||||
|
// handed to `Metrics::distance` — see `obikquery`'s own use of
|
||||||
|
// `IndexCache` for the same reasoning (one open, many in-memory reads).
|
||||||
|
let cache = IndexCache::new(Arc::clone(&idx), None);
|
||||||
|
|
||||||
|
let need_shared = args.shared_kmers || args.nj || args.upgma;
|
||||||
|
let t = Stage::start("distance");
|
||||||
|
let result = cache
|
||||||
|
.distance(args.metric.into(), need_shared, args.presence_threshold)
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("error computing distances: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
rep.push(t.stop());
|
||||||
|
|
||||||
|
// ── Distance matrix → CSV ─────────────────────────────────────────────────
|
||||||
|
let write_dist_csv = |w: &mut dyn Write| {
|
||||||
|
write!(w, "genome").unwrap();
|
||||||
|
for g in &labels { write!(w, ",{g}").unwrap(); }
|
||||||
|
writeln!(w).unwrap();
|
||||||
|
for (i, g) in labels.iter().enumerate() {
|
||||||
|
write!(w, "{g}").unwrap();
|
||||||
|
for j in 0..n {
|
||||||
|
write!(w, ",{:.6}", result.matrix[[i, j]]).unwrap();
|
||||||
|
}
|
||||||
|
writeln!(w).unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match &args.output {
|
||||||
|
Some(prefix) => {
|
||||||
|
let path = format!("{}_dist.csv", prefix.display());
|
||||||
|
let mut f = BufWriter::new(std::fs::File::create(&path).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error creating {path}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}));
|
||||||
|
write_dist_csv(&mut f);
|
||||||
|
info!("distance matrix → {path}");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut out = BufWriter::new(stdout.lock());
|
||||||
|
write_dist_csv(&mut out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared-kmer matrix → CSV ──────────────────────────────────────────────
|
||||||
|
if args.shared_kmers {
|
||||||
|
if let Some(shared) = &result.shared_kmers {
|
||||||
|
let path = args.output.as_ref()
|
||||||
|
.map(|p| format!("{}_shared.csv", p.display()))
|
||||||
|
.unwrap_or_else(|| "shared.csv".into());
|
||||||
|
let mut f = BufWriter::new(std::fs::File::create(&path).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error creating {path}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}));
|
||||||
|
write!(f, "genome").unwrap();
|
||||||
|
for g in &labels { write!(f, ",{g}").unwrap(); }
|
||||||
|
writeln!(f).unwrap();
|
||||||
|
for (i, g) in labels.iter().enumerate() {
|
||||||
|
write!(f, "{g}").unwrap();
|
||||||
|
for j in 0..n { write!(f, ",{}", shared[[i, j]]).unwrap(); }
|
||||||
|
writeln!(f).unwrap();
|
||||||
|
}
|
||||||
|
info!("shared-kmer matrix → {path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NJ tree ────────────────────────────────────────────────────────────────
|
||||||
|
if args.nj {
|
||||||
|
let newick = neighbor_joining_newick(&result.matrix, &labels).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error computing NJ tree: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
let path = args.output.as_ref()
|
||||||
|
.map(|p| format!("{}_nj.nwk", p.display()))
|
||||||
|
.unwrap_or_else(|| "nj.nwk".into());
|
||||||
|
std::fs::write(&path, &newick).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error writing {path}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
info!("NJ tree → {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPGMA tree ───────────────────────────────────────────────────────────────
|
||||||
|
if args.upgma {
|
||||||
|
let newick = upgma_newick(&result.matrix, &labels);
|
||||||
|
let path = args.output.as_ref()
|
||||||
|
.map(|p| format!("{}_upgma.nwk", p.display()))
|
||||||
|
.unwrap_or_else(|| "upgma.nwk".into());
|
||||||
|
std::fs::write(&path, &newick).unwrap_or_else(|e| {
|
||||||
|
eprintln!("error writing {path}: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
info!("UPGMA tree → {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
rep.print();
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ enum Commands {
|
|||||||
Utils(cmd::utils::UtilsArgs),
|
Utils(cmd::utils::UtilsArgs),
|
||||||
/// Convert an index's evidence representation (exact/approximate/hybrid), in place
|
/// Convert an index's evidence representation (exact/approximate/hybrid), in place
|
||||||
Convert(cmd::convert::ConvertArgs),
|
Convert(cmd::convert::ConvertArgs),
|
||||||
|
/// Genome-vs-genome distance matrix (+ optional NJ/UPGMA tree) — partial transfer,
|
||||||
|
/// sibling-annex-based operations are not yet ported (see obikmer's own `phylo`)
|
||||||
|
Phylo(cmd::phylo::PhyloArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -64,5 +67,6 @@ fn main() {
|
|||||||
Commands::Annotate(args) => cmd::annotate::run(args),
|
Commands::Annotate(args) => cmd::annotate::run(args),
|
||||||
Commands::Utils(args) => cmd::utils::run(args),
|
Commands::Utils(args) => cmd::utils::run(args),
|
||||||
Commands::Convert(args) => cmd::convert::run(args),
|
Commands::Convert(args) => cmd::convert::run(args),
|
||||||
|
Commands::Phylo(args) => cmd::phylo::run(args),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ obicompactvec = { path = "../obicompactvec" }
|
|||||||
obikidxcache = { path = "../obikidxcache" }
|
obikidxcache = { path = "../obikidxcache" }
|
||||||
obiskbuilder = { path = "../obiskbuilder" }
|
obiskbuilder = { path = "../obiskbuilder" }
|
||||||
obipipeline = { path = "../obipipeline" }
|
obipipeline = { path = "../obipipeline" }
|
||||||
|
kodama = "0.3.0"
|
||||||
memmap2 = "0.9"
|
memmap2 = "0.9"
|
||||||
ndarray = "0.17"
|
ndarray = "0.17"
|
||||||
|
petgraph = "0.6.4"
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
|
speedytree = "0.1"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
+123
-36
@@ -1,10 +1,23 @@
|
|||||||
use ndarray::Array2;
|
//! Genome-vs-genome distance matrices over a whole index — the aggregation
|
||||||
use obicompactvec::traits::{BitPartials, CountPartials};
|
//! (summing per-layer partials into one global partial) and the final
|
||||||
use obikidxcache::LayeredStore;
|
//! distance calculation are both phylogenetic domain logic, so both live
|
||||||
use rayon::prelude::*;
|
//! here, not in `obikidxcache` (which only ever hands out already-opened
|
||||||
|
//! `KmerLayer`s via `IndexCache::iter()` — plain exposition, no
|
||||||
|
//! computation of its own).
|
||||||
|
//!
|
||||||
|
//! [`AggregatedCount`]/[`AggregatedBit`] exist purely to "hang"
|
||||||
|
//! `obicompactvec`'s `CountPartials`/`BitPartials` traits on a borrowed
|
||||||
|
//! collection of `KmerLayer`s, so their already-written finalisation
|
||||||
|
//! methods (`bray_dist_matrix`, `jaccard_dist_matrix`, ...) can be reused
|
||||||
|
//! as-is instead of re-derived here — see `KmerLayer`'s own
|
||||||
|
//! `ColumnWeights`/`CountPartials`/`BitPartials` impls (`obikindex`) for
|
||||||
|
//! the per-layer primitives these sum.
|
||||||
|
|
||||||
|
use ndarray::{Array1, Array2};
|
||||||
|
use obicompactvec::{BitPartials, ColumnWeights, CountPartials};
|
||||||
|
use obikidxcache::index_cache::IndexCache;
|
||||||
|
use obikindex::layer::{KmerLayer, LayerContent};
|
||||||
use obikindex::{OKIError, OKIResult};
|
use obikindex::{OKIError, OKIResult};
|
||||||
use obikindex::KmerIndex;
|
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,42 +64,121 @@ impl DistanceMetric {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── KmerIndex::distance ───────────────────────────────────────────────────────
|
// ── Aggregators — sum every cached layer's own partials into one global
|
||||||
|
// partial, then hand the result off to `CountPartials`/`BitPartials`'s own
|
||||||
|
// provided finalisation methods.
|
||||||
|
|
||||||
impl KmerIndex {
|
fn sum_array1(n: usize, items: impl Iterator<Item = Array1<u64>>) -> Array1<u64> {
|
||||||
pub fn distance(&self, metric: DistanceMetric, shared_kmers: bool, presence_threshold: u32) -> OKIResult<DistanceOutput> {
|
items.fold(Array1::zeros(n), |acc, x| acc + x)
|
||||||
let n_genomes = self.meta.genomes().map_err(OKIError::Io)?.len();
|
}
|
||||||
|
fn sum_array2_u64(n: usize, items: impl Iterator<Item = Array2<u64>>) -> Array2<u64> {
|
||||||
|
items.fold(Array2::zeros((n, n)), |acc, x| acc + x)
|
||||||
|
}
|
||||||
|
fn sum_array2_f64(n: usize, items: impl Iterator<Item = Array2<f64>>) -> Array2<f64> {
|
||||||
|
items.fold(Array2::zeros((n, n)), |acc, x| acc + x)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AggregatedCount<'a> {
|
||||||
|
n_genomes: usize,
|
||||||
|
layers: Vec<&'a KmerLayer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColumnWeights for AggregatedCount<'_> {
|
||||||
|
fn col_weights(&self) -> Array1<u64> {
|
||||||
|
sum_array1(self.n_genomes, self.layers.iter().map(|l| l.col_weights()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CountPartials for AggregatedCount<'_> {
|
||||||
|
fn partial_bray(&self) -> Array2<u64> {
|
||||||
|
sum_array2_u64(self.n_genomes, self.layers.iter().map(|l| l.partial_bray()))
|
||||||
|
}
|
||||||
|
fn partial_euclidean(&self) -> Array2<f64> {
|
||||||
|
sum_array2_f64(self.n_genomes, self.layers.iter().map(|l| l.partial_euclidean()))
|
||||||
|
}
|
||||||
|
fn partial_threshold_jaccard(&self, threshold: u32) -> (Array2<u64>, Array2<u64>) {
|
||||||
|
let n = self.n_genomes;
|
||||||
|
self.layers.iter().map(|l| l.partial_threshold_jaccard(threshold)).fold(
|
||||||
|
(Array2::zeros((n, n)), Array2::zeros((n, n))),
|
||||||
|
|(inter, union), (i, u)| (inter + i, union + u),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn partial_relfreq_bray(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
sum_array2_f64(self.n_genomes, self.layers.iter().map(|l| l.partial_relfreq_bray(global)))
|
||||||
|
}
|
||||||
|
fn partial_relfreq_euclidean(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
sum_array2_f64(self.n_genomes, self.layers.iter().map(|l| l.partial_relfreq_euclidean(global)))
|
||||||
|
}
|
||||||
|
fn partial_hellinger(&self, global: &Array1<u64>) -> Array2<f64> {
|
||||||
|
sum_array2_f64(self.n_genomes, self.layers.iter().map(|l| l.partial_hellinger(global)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AggregatedBit<'a> {
|
||||||
|
n_genomes: usize,
|
||||||
|
layers: Vec<&'a KmerLayer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColumnWeights for AggregatedBit<'_> {
|
||||||
|
fn col_weights(&self) -> Array1<u64> {
|
||||||
|
sum_array1(self.n_genomes, self.layers.iter().map(|l| l.col_weights()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BitPartials for AggregatedBit<'_> {
|
||||||
|
fn partial_jaccard(&self) -> (Array2<u64>, Array2<u64>) {
|
||||||
|
let n = self.n_genomes;
|
||||||
|
self.layers.iter().map(|l| l.partial_jaccard()).fold(
|
||||||
|
(Array2::zeros((n, n)), Array2::zeros((n, n))),
|
||||||
|
|(inter, union), (i, u)| (inter + i, union + u),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn partial_hamming(&self) -> Array2<u64> {
|
||||||
|
sum_array2_u64(self.n_genomes, self.layers.iter().map(|l| l.partial_hamming()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metrics — the domain-facing entry point ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Genome-vs-genome distance computation over a whole (already-cached)
|
||||||
|
/// index. `IndexCache` is foreign to this crate, so this is an extension
|
||||||
|
/// trait — same reasoning as every other `obikindex`/`obikidxcache`
|
||||||
|
/// extension trait in this codebase.
|
||||||
|
pub trait Metrics {
|
||||||
|
fn distance(&self, metric: DistanceMetric, shared_kmers: bool, presence_threshold: u32) -> OKIResult<DistanceOutput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics for IndexCache {
|
||||||
|
fn distance(&self, metric: DistanceMetric, shared_kmers: bool, presence_threshold: u32) -> OKIResult<DistanceOutput> {
|
||||||
|
let n_genomes = self.meta().genomes().len();
|
||||||
if n_genomes < 2 {
|
if n_genomes < 2 {
|
||||||
return Err(OKIError::InvalidInput(
|
return Err(OKIError::InvalidInput(
|
||||||
"distance requires at least 2 genomes in the index".into(),
|
"distance requires at least 2 genomes in the index".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let use_counts = self.meta.config.with_counts;
|
let use_counts = self.meta().config().with_counts;
|
||||||
if metric.requires_counts() && !use_counts {
|
if metric.requires_counts() && !use_counts {
|
||||||
return Err(OKIError::InvalidInput(format!(
|
return Err(OKIError::InvalidInput(format!(
|
||||||
"{metric:?} requires a count index (with_counts = true)"
|
"{metric:?} requires a count index (with_counts = true)"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let n_parts = self.n_partitions();
|
let kmer_size = self.meta().config().kmer_size;
|
||||||
|
|
||||||
if use_counts {
|
if use_counts {
|
||||||
let stores: Vec<_> = (0..n_parts)
|
let layers: Vec<&KmerLayer> = self.iter().filter(|l| l.content() == LayerContent::Count).collect();
|
||||||
.into_par_iter()
|
let agg = AggregatedCount { n_genomes, layers };
|
||||||
.map(|i| self.count_store(i).map_err(OKIError::Partition))
|
|
||||||
.collect::<OKIResult<_>>()?;
|
|
||||||
let global = LayeredStore::new(stores);
|
|
||||||
|
|
||||||
let matrix = match metric {
|
let matrix = match metric {
|
||||||
DistanceMetric::BrayCurtis => CountPartials::bray_dist_matrix(&global),
|
DistanceMetric::BrayCurtis => agg.bray_dist_matrix(),
|
||||||
DistanceMetric::RelfreqBrayCurtis => CountPartials::relfreq_bray_dist_matrix(&global),
|
DistanceMetric::RelfreqBrayCurtis => agg.relfreq_bray_dist_matrix(),
|
||||||
DistanceMetric::Euclidean => CountPartials::euclidean_dist_matrix(&global),
|
DistanceMetric::Euclidean => agg.euclidean_dist_matrix(),
|
||||||
DistanceMetric::RelfreqEuclidean => CountPartials::relfreq_euclidean_dist_matrix(&global),
|
DistanceMetric::RelfreqEuclidean => agg.relfreq_euclidean_dist_matrix(),
|
||||||
DistanceMetric::Hellinger => CountPartials::hellinger_dist_matrix(&global),
|
DistanceMetric::Hellinger => agg.hellinger_dist_matrix(),
|
||||||
DistanceMetric::HellingerEuclidean => CountPartials::hellinger_euclidean_dist_matrix(&global),
|
DistanceMetric::HellingerEuclidean => agg.hellinger_euclidean_dist_matrix(),
|
||||||
DistanceMetric::Jaccard => CountPartials::threshold_jaccard_dist_matrix(&global, presence_threshold),
|
DistanceMetric::Jaccard => agg.threshold_jaccard_dist_matrix(presence_threshold),
|
||||||
DistanceMetric::Mash => CountPartials::threshold_mash_dist_matrix(&global, self.kmer_size(), presence_threshold),
|
DistanceMetric::Mash => agg.threshold_mash_dist_matrix(kmer_size, presence_threshold),
|
||||||
DistanceMetric::Hamming => {
|
DistanceMetric::Hamming => {
|
||||||
return Err(OKIError::InvalidInput(
|
return Err(OKIError::InvalidInput(
|
||||||
"Hamming is only available for presence/absence indexes".into(),
|
"Hamming is only available for presence/absence indexes".into(),
|
||||||
@@ -95,7 +187,7 @@ impl KmerIndex {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let shared = if shared_kmers {
|
let shared = if shared_kmers {
|
||||||
let (inter, _) = CountPartials::partial_threshold_jaccard(&global, presence_threshold);
|
let (inter, _) = agg.partial_threshold_jaccard(presence_threshold);
|
||||||
Some(inter)
|
Some(inter)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -103,18 +195,13 @@ impl KmerIndex {
|
|||||||
|
|
||||||
Ok(DistanceOutput { matrix, shared_kmers: shared })
|
Ok(DistanceOutput { matrix, shared_kmers: shared })
|
||||||
} else {
|
} else {
|
||||||
let stores: Vec<_> = (0..n_parts)
|
let layers: Vec<&KmerLayer> = self.iter().filter(|l| l.content() == LayerContent::Presence).collect();
|
||||||
.into_par_iter()
|
let agg = AggregatedBit { n_genomes, layers };
|
||||||
.map(|i| self.presence_store(i).map_err(OKIError::Partition))
|
|
||||||
.collect::<OKIResult<_>>()?;
|
|
||||||
let global = LayeredStore::new(stores);
|
|
||||||
|
|
||||||
let matrix = match metric {
|
let matrix = match metric {
|
||||||
DistanceMetric::Jaccard => BitPartials::jaccard_dist_matrix(&global),
|
DistanceMetric::Jaccard => agg.jaccard_dist_matrix(),
|
||||||
DistanceMetric::Mash => BitPartials::mash_dist_matrix(&global, self.kmer_size()),
|
DistanceMetric::Mash => agg.mash_dist_matrix(kmer_size),
|
||||||
DistanceMetric::Hamming => {
|
DistanceMetric::Hamming => agg.hamming_dist_matrix().mapv(|v| v as f64),
|
||||||
BitPartials::hamming_dist_matrix(&global).mapv(|v| v as f64)
|
|
||||||
}
|
|
||||||
other => {
|
other => {
|
||||||
return Err(OKIError::InvalidInput(format!(
|
return Err(OKIError::InvalidInput(format!(
|
||||||
"{other:?} requires a count index; use --metric jaccard or --metric hamming"
|
"{other:?} requires a count index; use --metric jaccard or --metric hamming"
|
||||||
@@ -123,7 +210,7 @@ impl KmerIndex {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let shared = if shared_kmers {
|
let shared = if shared_kmers {
|
||||||
let (inter, _) = BitPartials::partial_jaccard(&global);
|
let (inter, _) = agg.partial_jaccard();
|
||||||
Some(inter)
|
Some(inter)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Library-level phylogenetic functionality for `obikmer`, built as
|
//! Library-level phylogenetic functionality for `obikmer`, built as
|
||||||
//! extension traits over `obikindex::KmerIndex` and `obikindex::layer`'s
|
//! extension traits over `obikindex`/`obikidxcache`'s foreign types — the
|
||||||
//! generic layer types — the `phylo` CLI command is a consumer of this
|
//! `phylo` CLI command is a consumer of this crate, not the owner of this
|
||||||
//! crate, not the owner of this logic (see `DevDocMD/architecture/siblings.md`).
|
//! logic (see `DevDocMD/architecture/siblings.md`).
|
||||||
//!
|
//!
|
||||||
//! Starts with [`siblings`] (family presence-mask annex, SNP distance,
|
//! Starts with [`siblings`] (family presence-mask annex, SNP distance,
|
||||||
//! cardinality, pseudo-alignment); further phylo-domain functionality
|
//! cardinality, pseudo-alignment); further phylo-domain functionality
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
//! this crate until `siblings` comes back.
|
//! this crate until `siblings` comes back.
|
||||||
|
|
||||||
mod distance;
|
mod distance;
|
||||||
mod matrix_store;
|
mod tree;
|
||||||
// pub mod siblings; // temporarily disconnected — see module doc above.
|
// pub mod siblings; // temporarily disconnected — see module doc above.
|
||||||
|
|
||||||
pub use distance::{DistanceMetric, DistanceOutput};
|
pub use distance::{DistanceMetric, DistanceOutput, Metrics};
|
||||||
|
pub use tree::{neighbor_joining_newick, upgma_newick};
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
use obicompactvec::{PersistentBitMatrix, PersistentIntMatrix};
|
|
||||||
use obikidxcache::LayeredStore;
|
|
||||||
use obikindex::OKIResult;
|
|
||||||
use obikindex::layer::open_data;
|
|
||||||
|
|
||||||
use obikindex::KmerIndex;
|
|
||||||
use obikindex::load_meta;
|
|
||||||
|
|
||||||
impl KmerIndex {
|
|
||||||
/// Open all count matrices for partition `part`, one per layer.
|
|
||||||
/// Layers without a `counts/` directory are skipped.
|
|
||||||
pub fn count_store(&self, part: usize) -> OKIResult<LayeredStore<PersistentIntMatrix>> {
|
|
||||||
let index_dir = self.index_dir(part);
|
|
||||||
if !index_dir.exists() {
|
|
||||||
return Ok(LayeredStore::new(vec![]));
|
|
||||||
}
|
|
||||||
let n_layers = self.n_layers();
|
|
||||||
let matrices = (0..n_layers)
|
|
||||||
.filter_map(|l| {
|
|
||||||
self.layer_dir(part, l)
|
|
||||||
.join("counts")
|
|
||||||
.exists()
|
|
||||||
.then(|| open_data(&index_dir, l))
|
|
||||||
})
|
|
||||||
.collect::<OKIResult<Vec<_>>>()?;
|
|
||||||
Ok(LayeredStore::new(matrices))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open all presence matrices for partition `part`, one per layer.
|
|
||||||
/// Layers without a `presence/` directory are skipped.
|
|
||||||
pub fn presence_store(&self, part: usize) -> OKIResult<LayeredStore<PersistentBitMatrix>> {
|
|
||||||
let index_dir = self.index_dir(part);
|
|
||||||
if !index_dir.exists() {
|
|
||||||
return Ok(LayeredStore::new(vec![]));
|
|
||||||
}
|
|
||||||
let n_layers = load_meta(&index_dir)?.n_layers;
|
|
||||||
let matrices = (0..n_layers)
|
|
||||||
.filter_map(|l| {
|
|
||||||
self.layer_dir(part, l)
|
|
||||||
.join("presence")
|
|
||||||
.exists()
|
|
||||||
.then(|| open_data(&index_dir, l))
|
|
||||||
})
|
|
||||||
.collect::<OKIResult<Vec<_>>>()?;
|
|
||||||
Ok(LayeredStore::new(matrices))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! A minimal, write-only phylogenetic tree: just enough structure to unify
|
||||||
|
//! whatever a reconstruction algorithm produces (NJ, UPGMA, ...) into one
|
||||||
|
//! shared Newick serialisation, instead of each algorithm hand-rolling its
|
||||||
|
//! own Newick writer. No reader — nothing in this crate ever needs to parse
|
||||||
|
//! a Newick string back in, so there is no `from_newick`.
|
||||||
|
|
||||||
|
mod newick;
|
||||||
|
mod nj;
|
||||||
|
mod upgma;
|
||||||
|
|
||||||
|
pub use nj::neighbor_joining_newick;
|
||||||
|
pub use upgma::upgma_newick;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct NodeId(usize);
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Node {
|
||||||
|
name: Option<String>,
|
||||||
|
branch_length: Option<f64>,
|
||||||
|
bootstrap: Option<f64>,
|
||||||
|
children: Vec<NodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arena-based tree: nodes are created detached ([`new_leaf`](Tree::new_leaf)/
|
||||||
|
/// [`new_node`](Tree::new_node)) and wired together with
|
||||||
|
/// [`attach`](Tree::attach) — the shape every bottom-up reconstruction
|
||||||
|
/// algorithm (UPGMA's successive merges, NJ's already-built graph) needs:
|
||||||
|
/// a node's parent is only known once *it* already exists.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct Tree {
|
||||||
|
nodes: Vec<Node>,
|
||||||
|
root: Option<NodeId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tree {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new, detached leaf node.
|
||||||
|
fn new_leaf(&mut self, name: impl Into<String>) -> NodeId {
|
||||||
|
let id = self.new_node();
|
||||||
|
self.nodes[id.0].name = Some(name.into());
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new, detached internal node (no name).
|
||||||
|
fn new_node(&mut self) -> NodeId {
|
||||||
|
let id = NodeId(self.nodes.len());
|
||||||
|
self.nodes.push(Node::default());
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach `child` under `parent`, recording the branch length between them.
|
||||||
|
fn attach(&mut self, parent: NodeId, child: NodeId, branch_length: f64) {
|
||||||
|
self.nodes[child.0].branch_length = Some(branch_length);
|
||||||
|
self.nodes[parent.0].children.push(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a bootstrap/support value for `node` — meaningful on an
|
||||||
|
/// internal node only; printed right after its closing parenthesis, per
|
||||||
|
/// Newick convention.
|
||||||
|
#[allow(dead_code)] // no current caller sets one (NJ/UPGMA don't produce support values); kept for the next algorithm that does.
|
||||||
|
fn set_bootstrap(&mut self, node: NodeId, value: f64) {
|
||||||
|
self.nodes[node.0].bootstrap = Some(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark `node` as the tree's root. Required exactly once before
|
||||||
|
/// [`to_newick`](Tree::to_newick).
|
||||||
|
fn set_root(&mut self, node: NodeId) {
|
||||||
|
self.root = Some(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise to Newick (terminated with `;`). Panics if
|
||||||
|
/// [`set_root`](Tree::set_root) was never called — a tree with no
|
||||||
|
/// designated root is a builder bug, not a recoverable input.
|
||||||
|
fn to_newick(&self) -> String {
|
||||||
|
let root = self.root.expect("Tree::to_newick: root not set");
|
||||||
|
let mut out = newick::write_node(self, root);
|
||||||
|
out.push(';');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node(&self, id: NodeId) -> &Node {
|
||||||
|
&self.nodes[id.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! The one Newick writer every tree-reconstruction algorithm in this
|
||||||
|
//! module shares.
|
||||||
|
|
||||||
|
use super::{Node, NodeId, Tree};
|
||||||
|
|
||||||
|
pub(super) fn write_node(tree: &Tree, id: NodeId) -> String {
|
||||||
|
let node: &Node = tree.node(id);
|
||||||
|
let mut s = String::new();
|
||||||
|
|
||||||
|
if node.children.is_empty() {
|
||||||
|
if let Some(name) = &node.name {
|
||||||
|
s.push_str(name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.push('(');
|
||||||
|
for (i, &child) in node.children.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
s.push(',');
|
||||||
|
}
|
||||||
|
s.push_str(&write_node(tree, child));
|
||||||
|
}
|
||||||
|
s.push(')');
|
||||||
|
if let Some(bootstrap) = node.bootstrap {
|
||||||
|
s.push_str(&format!("{bootstrap}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(branch_length) = node.branch_length {
|
||||||
|
s.push(':');
|
||||||
|
s.push_str(&format!("{branch_length:.6}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
s
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//! Neighbor-Joining, via `speedytree` — converts its own tree type
|
||||||
|
//! (`petgraph::graph::UnGraph<String, f64>`) into this crate's [`super::Tree`]
|
||||||
|
//! so its Newick output goes through the same writer as every other
|
||||||
|
//! algorithm here.
|
||||||
|
|
||||||
|
use ndarray::Array2;
|
||||||
|
use obikindex::{OKIError, OKIResult};
|
||||||
|
use petgraph::graph::NodeIndex;
|
||||||
|
use speedytree::{DistanceMatrix, Hybrid, NeighborJoiningSolver};
|
||||||
|
|
||||||
|
use super::{NodeId, Tree};
|
||||||
|
|
||||||
|
/// Compute a Neighbor-Joining tree from a symmetric `n×n` distance `matrix`
|
||||||
|
/// (genomes in `labels` order), returning it as a Newick string.
|
||||||
|
pub fn neighbor_joining_newick(matrix: &Array2<f64>, labels: &[String]) -> OKIResult<String> {
|
||||||
|
let n = labels.len();
|
||||||
|
let rows: Vec<Vec<f64>> = (0..n).map(|i| (0..n).map(|j| matrix[[i, j]]).collect()).collect();
|
||||||
|
let dm = DistanceMatrix::build(rows, labels.to_vec())
|
||||||
|
.map_err(|e| OKIError::InvalidInput(format!("building NJ distance matrix: {e}")))?;
|
||||||
|
let graph = NeighborJoiningSolver::<Hybrid>::default(dm)
|
||||||
|
.solve()
|
||||||
|
.map_err(|e| OKIError::InvalidInput(format!("computing NJ tree: {e}")))?;
|
||||||
|
|
||||||
|
// speedytree's own Newick writer expects the same convention: the
|
||||||
|
// unrooted result's "display root" is whichever node still has all
|
||||||
|
// three of its original neighbours (every other node lost one to
|
||||||
|
// becoming somebody's child during the walk below).
|
||||||
|
let root = graph
|
||||||
|
.node_indices()
|
||||||
|
.find(|&node| graph.neighbors(node).count() == 3)
|
||||||
|
.ok_or_else(|| OKIError::InvalidInput("NJ tree has no trifurcating root".into()))?;
|
||||||
|
|
||||||
|
let mut tree = Tree::new();
|
||||||
|
let root_id = convert(&graph, root, root, &mut tree);
|
||||||
|
tree.set_root(root_id);
|
||||||
|
Ok(tree.to_newick())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively convert `node` (and everything below it, i.e. every
|
||||||
|
/// neighbour except `parent`) into this crate's `Tree`. Excluding just the
|
||||||
|
/// immediate parent is enough to avoid revisiting — `graph` is a tree
|
||||||
|
/// (no cycles), so that's the only edge that could lead back.
|
||||||
|
fn convert(graph: &speedytree::Tree, node: NodeIndex, parent: NodeIndex, tree: &mut Tree) -> NodeId {
|
||||||
|
let label = &graph[node];
|
||||||
|
let id = if label.is_empty() { tree.new_node() } else { tree.new_leaf(label.clone()) };
|
||||||
|
|
||||||
|
for child in graph.neighbors(node).filter(|&c| c != parent) {
|
||||||
|
let edge = graph.find_edge(node, child).expect("adjacent nodes must share an edge");
|
||||||
|
let branch_length = *graph.edge_weight(edge).expect("edge must carry a weight");
|
||||||
|
let child_id = convert(graph, child, node, tree);
|
||||||
|
tree.attach(id, child_id, branch_length);
|
||||||
|
}
|
||||||
|
|
||||||
|
id
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! UPGMA (average-linkage hierarchical clustering), via `kodama` — converts
|
||||||
|
//! its dendrogram (a flat list of merge steps) into this crate's
|
||||||
|
//! [`super::Tree`], bottom-up, so its Newick output goes through the same
|
||||||
|
//! writer as every other algorithm here.
|
||||||
|
|
||||||
|
use kodama::{Method, linkage};
|
||||||
|
use ndarray::Array2;
|
||||||
|
|
||||||
|
use super::Tree;
|
||||||
|
|
||||||
|
/// Compute a UPGMA tree from a symmetric `n×n` distance `matrix` (genomes in
|
||||||
|
/// `labels` order), returning it as a Newick string.
|
||||||
|
pub fn upgma_newick(matrix: &Array2<f64>, labels: &[String]) -> String {
|
||||||
|
let n = labels.len();
|
||||||
|
let mut condensed: Vec<f64> = Vec::with_capacity(n * (n - 1) / 2);
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
condensed.push(matrix[[i, j]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dendro = linkage(&mut condensed, n, Method::Average);
|
||||||
|
|
||||||
|
let mut tree = Tree::new();
|
||||||
|
// `kodama` numbers clusters `0..n` for the original leaves and `n..`
|
||||||
|
// for each successive merge, in the order `dendro.steps()` yields them
|
||||||
|
// — so appending to `node_of` as we go keeps `node_of[cluster_id]`
|
||||||
|
// valid the moment a later step references it.
|
||||||
|
let mut node_of: Vec<super::NodeId> =
|
||||||
|
labels.iter().map(|label| tree.new_leaf(label.clone())).collect();
|
||||||
|
// height of each node: leaves = 0, internal = dissimilarity/2 (UPGMA is
|
||||||
|
// ultrametric: both children of a merge are equidistant from it).
|
||||||
|
let mut heights = vec![0.0f64; 2 * n - 1];
|
||||||
|
|
||||||
|
let mut root = None;
|
||||||
|
for (k, step) in dendro.steps().iter().enumerate() {
|
||||||
|
let new_node = n + k;
|
||||||
|
let h = step.dissimilarity / 2.0;
|
||||||
|
heights[new_node] = h;
|
||||||
|
|
||||||
|
let parent = tree.new_node();
|
||||||
|
for &cluster in &[step.cluster1, step.cluster2] {
|
||||||
|
let branch_length = (h - heights[cluster]).max(0.0);
|
||||||
|
tree.attach(parent, node_of[cluster], branch_length);
|
||||||
|
}
|
||||||
|
node_of.push(parent);
|
||||||
|
root = Some(parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.set_root(root.expect("at least one merge step for n >= 2 genomes"));
|
||||||
|
tree.to_newick()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user