use ann_search_rs::prelude::*;
use ann_search_rs::utils::k_means_utils::{assign_all_parallel, train_centroids};
use faer::Mat;
use indexmap::IndexSet;
use rand::{Rng, SeedableRng, rngs::SmallRng};
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use thousands::Separable;
use crate::ml::clustering::k_means::*;
use crate::prelude::*;
use crate::single_cell::sc_processing::pca::{SingleCellPcaParams, pca_on_sc_streaming};
use crate::single_cell::sc_utils::simd::*;
use crate::single_cell::sc_utils::utils_tree::*;
pub(crate) const SCENIC_GENE_CHUNK_SIZE: usize = 1024;
pub const MULTI_OUTPUT_BATCH: usize = 64;
const GBM_SUBTRACTION_CELL_THRESHOLD: usize = 10_000;
#[derive(Clone, Debug)]
pub enum RegressionLearner {
ExtraTrees(ExtraTreesConfig),
RandomForest(RandomForestConfig),
GradientBoosting(GradientBoostingConfig),
}
impl Default for RegressionLearner {
fn default() -> Self {
RegressionLearner::GradientBoosting(GradientBoostingConfig::default())
}
}
pub fn parse_regression_learner(s: &str) -> Option<RegressionLearner> {
match s.to_lowercase().as_str() {
"extratrees" => Some(RegressionLearner::ExtraTrees(ExtraTreesConfig::default())),
"rf" | "randomforest" => Some(RegressionLearner::RandomForest(
RandomForestConfig::default(),
)),
"boosted" | "grnboost2" | "lightgbm" => Some(RegressionLearner::GradientBoosting(
GradientBoostingConfig::default(),
)),
_ => None,
}
}
pub trait TreeRegressorConfig: Sync {
fn n_trees(&self) -> usize;
fn min_samples_leaf(&self) -> usize;
fn n_features_split(&self) -> usize;
fn random_threshold(&self) -> bool;
fn subsample_rate(&self) -> f32 {
1.0
}
fn bootstrap(&self) -> bool {
false
}
fn max_depth(&self) -> Option<usize> {
None
}
fn min_variance(&self) -> f32 {
1e-10
}
fn n_thresholds(&self) -> usize {
1
}
fn subsample_frac(&self) -> Option<f32> {
None
}
}
#[derive(Clone, Debug)]
pub struct ExtraTreesConfig {
pub n_trees: usize,
pub min_samples_leaf: usize,
pub n_features_split: usize,
pub n_thresholds: usize,
pub max_depth: Option<usize>,
pub subsample_frac: Option<f32>,
}
impl Default for ExtraTreesConfig {
fn default() -> Self {
Self {
n_trees: 500,
min_samples_leaf: 50,
n_features_split: 0,
n_thresholds: 1,
max_depth: Some(10),
subsample_frac: None,
}
}
}
impl TreeRegressorConfig for ExtraTreesConfig {
fn n_trees(&self) -> usize {
self.n_trees
}
fn min_samples_leaf(&self) -> usize {
self.min_samples_leaf
}
fn n_features_split(&self) -> usize {
self.n_features_split
}
fn random_threshold(&self) -> bool {
true
}
fn n_thresholds(&self) -> usize {
self.n_thresholds
}
fn max_depth(&self) -> Option<usize> {
self.max_depth
}
fn subsample_frac(&self) -> Option<f32> {
self.subsample_frac
}
}
#[derive(Clone, Debug)]
pub struct RandomForestConfig {
pub n_trees: usize,
pub min_samples_leaf: usize,
pub n_features_split: usize,
pub subsample_rate: f32,
pub bootstrap: bool,
pub max_depth: Option<usize>,
pub subsample_frac: Option<f32>,
}
impl Default for RandomForestConfig {
fn default() -> Self {
Self {
n_trees: 250,
min_samples_leaf: 50,
n_features_split: 0,
subsample_rate: 0.632,
bootstrap: false,
max_depth: Some(10),
subsample_frac: None,
}
}
}
impl TreeRegressorConfig for RandomForestConfig {
fn n_trees(&self) -> usize {
self.n_trees
}
fn min_samples_leaf(&self) -> usize {
self.min_samples_leaf
}
fn n_features_split(&self) -> usize {
self.n_features_split
}
fn random_threshold(&self) -> bool {
false
}
fn subsample_rate(&self) -> f32 {
self.subsample_rate
}
fn bootstrap(&self) -> bool {
self.bootstrap
}
fn max_depth(&self) -> Option<usize> {
self.max_depth
}
fn subsample_frac(&self) -> Option<f32> {
self.subsample_frac
}
}
#[derive(Clone, Debug)]
pub struct GradientBoostingConfig {
pub n_trees_max: usize,
pub learning_rate: f32,
pub max_depth: usize,
pub min_samples_leaf: usize,
pub early_stop_window: usize,
pub subsample_rate: f32,
pub n_features_split: usize,
}
impl Default for GradientBoostingConfig {
fn default() -> Self {
Self {
n_trees_max: 1000,
learning_rate: 0.01,
max_depth: 3,
min_samples_leaf: 50,
early_stop_window: 25,
subsample_rate: 0.9,
n_features_split: 0,
}
}
}
pub struct SparseYBatch {
pub offsets: Vec<u32>,
pub target_indices: Vec<u8>,
pub values: Vec<f32>,
}
impl SparseYBatch {
pub fn from_targets(
targets: &[SparseAxis<u32, f32>],
n_cells: usize,
) -> Result<Self, BixverseErrors> {
let mut counts_per_cell = vec![0u32; n_cells];
for target in targets {
let (indices, _) = target.get_indices_data_2()?;
for &idx in indices {
counts_per_cell[idx] += 1;
}
}
let mut offsets = Vec::with_capacity(n_cells + 1);
offsets.push(0u32);
let mut running = 0u32;
for &c in &counts_per_cell {
running += c;
offsets.push(running);
}
let total_nnz = running as usize;
let mut target_indices = vec![0u8; total_nnz];
let mut values = vec![0.0f32; total_nnz];
let mut cursor = vec![0u32; n_cells];
for (k, target) in targets.iter().enumerate() {
let (indices, data) = target.get_indices_data_2()?;
for (i, &cell) in indices.iter().enumerate() {
let pos = (offsets[cell] + cursor[cell]) as usize;
target_indices[pos] = k as u8;
values[pos] = data[i];
cursor[cell] += 1;
}
}
Ok(Self {
offsets,
target_indices,
values,
})
}
#[inline(always)]
fn cell_entries(&self, cell: usize) -> (&[u8], &[f32]) {
let s = self.offsets[cell] as usize;
let e = self.offsets[cell + 1] as usize;
(&self.target_indices[s..e], &self.values[s..e])
}
}
#[allow(dead_code)]
fn build_y_dense_multi(
targets: &[SparseAxis<u32, f32>],
n_samples: usize,
) -> Result<Vec<f32>, BixverseErrors> {
let n_targets = targets.len();
let mut y_dense = vec![0.0f32; n_samples * n_targets];
for (k, target) in targets.iter().enumerate() {
let (y_indices, y_data) = target.get_indices_data_2()?;
for (i, &idx) in y_indices.iter().enumerate() {
y_dense[idx * n_targets + k] = y_data[i];
}
}
Ok(y_dense)
}
#[allow(dead_code)]
struct TreeBuffers {
feat_buf: Vec<usize>,
left_buf: Vec<u32>,
right_buf: Vec<u32>,
left_y_buf: Vec<f32>,
right_y_buf: Vec<f32>,
counts: [u32; 256],
y_sums: Vec<f32>,
y_sum_sqs: Vec<f32>,
cum_counts: [u32; 256],
cum_y_sums: Vec<f32>,
cum_y_sum_sqs: Vec<f32>,
best_y_sums_l: Vec<f32>,
best_y_sum_sqs_l: Vec<f32>,
parent_vars: Vec<f32>,
}
impl TreeBuffers {
#[allow(dead_code)]
fn new(n_features: usize, n_samples: usize, n_targets: usize) -> Self {
Self {
feat_buf: (0..n_features).collect(),
left_buf: vec![0; n_samples],
right_buf: vec![0; n_samples],
left_y_buf: vec![0.0; n_samples * n_targets],
right_y_buf: vec![0.0; n_samples * n_targets],
counts: [0; 256],
y_sums: vec![0_f32; 256 * n_targets],
y_sum_sqs: vec![0_f32; 256 * n_targets],
cum_counts: [0; 256],
cum_y_sums: vec![0_f32; 256 * n_targets],
cum_y_sum_sqs: vec![0_f32; 256 * n_targets],
best_y_sums_l: vec![0_f32; n_targets],
best_y_sum_sqs_l: vec![0_f32; n_targets],
parent_vars: vec![0_f32; n_targets],
}
}
fn new_sparse(n_features: usize, n_samples: usize, n_targets: usize) -> Self {
Self {
feat_buf: (0..n_features).collect(),
left_buf: vec![0; n_samples],
right_buf: vec![0; n_samples],
left_y_buf: Vec::new(),
right_y_buf: Vec::new(),
counts: [0; 256],
y_sums: vec![0_f32; 256 * n_targets],
y_sum_sqs: vec![0_f32; 256 * n_targets],
cum_counts: [0; 256],
cum_y_sums: vec![0_f32; 256 * n_targets],
cum_y_sum_sqs: vec![0_f32; 256 * n_targets],
best_y_sums_l: vec![0_f32; n_targets],
best_y_sum_sqs_l: vec![0_f32; n_targets],
parent_vars: vec![0_f32; n_targets],
}
}
#[inline]
#[allow(dead_code)]
fn build_histograms(
&mut self,
tf_col: &[u8],
sample_slice: &[u32],
y_slice: &[f32],
n_targets: usize,
) -> (usize, usize) {
if sample_slice.is_empty() {
return (0, 0);
}
let mut min_bin = 255u8;
let mut max_bin = 0u8;
for &s in sample_slice {
let bin = tf_col[s as usize];
if bin < min_bin {
min_bin = bin;
}
if bin > max_bin {
max_bin = bin;
}
}
let min_b = min_bin as usize;
let max_b = max_bin as usize;
self.counts[min_b..=max_b].fill(0);
let start_idx = min_b * n_targets;
let end_idx = (max_b + 1) * n_targets;
self.y_sums[start_idx..end_idx].fill(0.0);
self.y_sum_sqs[start_idx..end_idx].fill(0.0);
for i in 0..sample_slice.len() {
let bin = tf_col[sample_slice[i] as usize] as usize;
self.counts[bin] += 1;
let y_base = i * n_targets;
let h_base = bin * n_targets;
for k in 0..n_targets {
let y = y_slice[y_base + k];
self.y_sums[h_base + k] += y;
self.y_sum_sqs[h_base + k] += y * y;
}
}
self.cum_counts[min_b] = self.counts[min_b];
let start_h = min_b * n_targets;
let next_h = (min_b + 1) * n_targets;
self.cum_y_sums[start_h..next_h].copy_from_slice(&self.y_sums[start_h..next_h]);
self.cum_y_sum_sqs[start_h..next_h].copy_from_slice(&self.y_sum_sqs[start_h..next_h]);
for b in (min_b + 1)..=max_b {
self.cum_counts[b] = self.cum_counts[b - 1] + self.counts[b];
let prev = (b - 1) * n_targets;
let curr = b * n_targets;
for k in 0..n_targets {
self.cum_y_sums[curr + k] = self.cum_y_sums[prev + k] + self.y_sums[curr + k];
self.cum_y_sum_sqs[curr + k] =
self.cum_y_sum_sqs[prev + k] + self.y_sum_sqs[curr + k];
}
}
(min_b, max_b)
}
#[inline]
fn build_histograms_sparse(
&mut self,
tf_col: &[u8],
sample_slice: &[u32],
sparse_y: &SparseYBatch,
n_targets: usize,
) -> (usize, usize) {
if sample_slice.is_empty() {
return (0, 0);
}
let mut min_bin = 255u8;
let mut max_bin = 0u8;
for &s in sample_slice {
let bin = tf_col[s as usize];
if bin < min_bin {
min_bin = bin;
}
if bin > max_bin {
max_bin = bin;
}
}
let min_b = min_bin as usize;
let max_b = max_bin as usize;
self.counts[min_b..=max_b].fill(0);
let start_idx = min_b * n_targets;
let end_idx = (max_b + 1) * n_targets;
self.y_sums[start_idx..end_idx].fill(0.0);
self.y_sum_sqs[start_idx..end_idx].fill(0.0);
for &s in sample_slice {
let bin = tf_col[s as usize] as usize;
self.counts[bin] += 1;
let (tgt_indices, tgt_values) = sparse_y.cell_entries(s as usize);
let h_base = bin * n_targets;
for i in 0..tgt_indices.len() {
let k = tgt_indices[i] as usize;
let y = tgt_values[i];
self.y_sums[h_base + k] += y;
self.y_sum_sqs[h_base + k] += y * y;
}
}
self.cum_counts[min_b] = self.counts[min_b];
let start_h = min_b * n_targets;
let next_h = (min_b + 1) * n_targets;
self.cum_y_sums[start_h..next_h].copy_from_slice(&self.y_sums[start_h..next_h]);
self.cum_y_sum_sqs[start_h..next_h].copy_from_slice(&self.y_sum_sqs[start_h..next_h]);
for b in (min_b + 1)..=max_b {
self.cum_counts[b] = self.cum_counts[b - 1] + self.counts[b];
let prev = (b - 1) * n_targets;
let curr = b * n_targets;
self.cum_y_sums[curr..curr + n_targets]
.copy_from_slice(&self.y_sums[curr..curr + n_targets]);
self.cum_y_sum_sqs[curr..curr + n_targets]
.copy_from_slice(&self.y_sum_sqs[curr..curr + n_targets]);
let mut prev_sums = [0_f32; MULTI_OUTPUT_BATCH];
let mut prev_sum_sqs = [0_f32; MULTI_OUTPUT_BATCH];
prev_sums[..n_targets].copy_from_slice(&self.cum_y_sums[prev..prev + n_targets]);
prev_sum_sqs[..n_targets].copy_from_slice(&self.cum_y_sum_sqs[prev..prev + n_targets]);
accumulate_f32_simd(
&mut self.cum_y_sums[curr..curr + n_targets],
&prev_sums[..n_targets],
n_targets,
);
accumulate_f32_simd(
&mut self.cum_y_sum_sqs[curr..curr + n_targets],
&prev_sum_sqs[..n_targets],
n_targets,
);
}
(min_b, max_b)
}
}
#[inline]
fn node_variance_f32(sum: f32, sum_sq: f32, n: usize) -> f32 {
if n < 2 {
return 0.0;
}
let nf = n as f32;
f32::max(0.0, sum_sq / nf - (sum / nf) * (sum / nf))
}
fn extract_and_normalise_multi_importances(
importances: &[f32],
n_features: usize,
n_targets: usize,
) -> Vec<Vec<f32>> {
let mut result = Vec::with_capacity(n_targets);
for k in 0..n_targets {
let mut target_imp = Vec::with_capacity(n_features);
for f in 0..n_features {
target_imp.push(importances[f * n_targets + k]);
}
normalise_importances(&mut target_imp);
result.push(target_imp);
}
result
}
#[inline(always)]
#[allow(clippy::too_many_arguments)]
fn evaluate_split_multi(
threshold: usize,
feat: usize,
parent_vars: &[f32],
n: usize,
y_sums_total: &[f32],
y_sum_sqs_total: &[f32],
cum_counts: &[u32; 256],
cum_y_sums: &[f32],
cum_y_sum_sqs: &[f32],
n_targets: usize,
min_samples_leaf: u32,
best_score: &mut f32,
best_feature: &mut usize,
best_threshold_u8: &mut u8,
best_n_left: &mut u32,
best_y_sums_l: &mut [f32],
best_y_sum_sqs_l: &mut [f32],
) {
let n_left = cum_counts[threshold];
let n_right = n as u32 - n_left;
if n_left < min_samples_leaf || n_right < min_samples_leaf {
return;
}
let nl = n_left as f32;
let nr = n_right as f32;
let nf = n as f32;
let h_base = threshold * n_targets;
let score = evaluate_split_score_f32_simd(
parent_vars,
y_sums_total,
y_sum_sqs_total,
&cum_y_sums[h_base..h_base + n_targets],
&cum_y_sum_sqs[h_base..h_base + n_targets],
n_targets,
1.0 / nl,
1.0 / nr,
nl / nf,
nr / nf,
);
if score > *best_score {
*best_score = score;
*best_feature = feat;
*best_threshold_u8 = threshold as u8;
*best_n_left = n_left;
best_y_sums_l.copy_from_slice(&cum_y_sums[h_base..h_base + n_targets]);
best_y_sum_sqs_l.copy_from_slice(&cum_y_sum_sqs[h_base..h_base + n_targets]);
}
}
#[allow(clippy::too_many_arguments)]
fn build_node_multi_sparse(
sparse_y: &SparseYBatch,
x: &QuantisedStore,
sample_slice: &mut [u32],
y_sums: &[f32],
y_sum_sqs: &[f32],
n_total: usize,
n_targets: usize,
n_features_split: usize,
config: &dyn TreeRegressorConfig,
depth: usize,
importances: &mut [f32],
bufs: &mut TreeBuffers,
rng: &mut SmallRng,
) {
let n = sample_slice.len();
let mut total_parent_var = 0.0f32;
for k in 0..n_targets {
let v = node_variance_f32(y_sums[k], y_sum_sqs[k], n);
bufs.parent_vars[k] = v;
total_parent_var += v;
}
let max_depth_reached = config.max_depth().is_some_and(|d| depth >= d);
if n < 2 * config.min_samples_leaf()
|| (total_parent_var) < config.min_variance()
|| max_depth_reached
{
return;
}
let n_features = x.n_features;
let k_feats = n_features_split.min(n_features);
for i in 0..k_feats {
let j = rng.random_range(i..n_features);
bufs.feat_buf.swap(i, j);
}
let mut best_score = 0.0f32;
let mut best_feature = usize::MAX;
let mut best_threshold_u8 = 0u8;
let mut best_n_left = 0u32;
for fi_idx in 0..k_feats {
let feat = bufs.feat_buf[fi_idx];
let tf_col = x.get_col(feat);
let (min_bin, max_bin) =
bufs.build_histograms_sparse(tf_col, sample_slice, sparse_y, n_targets);
if min_bin == max_bin {
continue;
}
if config.random_threshold() {
for _ in 0..config.n_thresholds() {
let threshold = rng.random_range(min_bin..max_bin);
evaluate_split_multi(
threshold,
feat,
&bufs.parent_vars,
n,
y_sums,
y_sum_sqs,
&bufs.cum_counts,
&bufs.cum_y_sums,
&bufs.cum_y_sum_sqs,
n_targets,
config.min_samples_leaf() as u32,
&mut best_score,
&mut best_feature,
&mut best_threshold_u8,
&mut best_n_left,
&mut bufs.best_y_sums_l,
&mut bufs.best_y_sum_sqs_l,
);
}
} else {
for threshold in min_bin..max_bin {
evaluate_split_multi(
threshold,
feat,
&bufs.parent_vars,
n,
y_sums,
y_sum_sqs,
&bufs.cum_counts,
&bufs.cum_y_sums,
&bufs.cum_y_sum_sqs,
n_targets,
config.min_samples_leaf() as u32,
&mut best_score,
&mut best_feature,
&mut best_threshold_u8,
&mut best_n_left,
&mut bufs.best_y_sums_l,
&mut bufs.best_y_sum_sqs_l,
);
}
}
}
if best_feature == usize::MAX {
return;
}
let nl = best_n_left as f32;
let nr = (n as u32 - best_n_left) as f32;
let nf = n as f32;
let weight = nf / n_total as f32;
let imp_base = best_feature * n_targets;
for k in 0..n_targets {
let y_sum_l = bufs.best_y_sums_l[k];
let y_sum_sq_l = bufs.best_y_sum_sqs_l[k];
let y_sum_r = y_sums[k] - y_sum_l;
let y_sum_sq_r = y_sum_sqs[k] - y_sum_sq_l;
let mean_l = y_sum_l / nl;
let var_l = f32::max(0.0, y_sum_sq_l / nl - mean_l * mean_l);
let mean_r = y_sum_r / nr;
let var_r = f32::max(0.0, y_sum_sq_r / nr - mean_r * mean_r);
let reduction = bufs.parent_vars[k] - (nl / nf) * var_l - (nr / nf) * var_r;
importances[imp_base + k] += weight * f32::max(0.0, reduction);
}
let mut left_y_sums = [0.0f32; MULTI_OUTPUT_BATCH];
let mut left_y_sum_sqs = [0.0f32; MULTI_OUTPUT_BATCH];
left_y_sums[..n_targets].copy_from_slice(&bufs.best_y_sums_l[..n_targets]);
left_y_sum_sqs[..n_targets].copy_from_slice(&bufs.best_y_sum_sqs_l[..n_targets]);
let mut right_y_sums = [0.0f32; MULTI_OUTPUT_BATCH];
let mut right_y_sum_sqs = [0.0f32; MULTI_OUTPUT_BATCH];
for k in 0..n_targets {
right_y_sums[k] = y_sums[k] - left_y_sums[k];
right_y_sum_sqs[k] = y_sum_sqs[k] - left_y_sum_sqs[k];
}
let tf_col = x.get_col(best_feature);
let mut l_idx = 0usize;
let mut r_idx = 0usize;
for i in 0..n {
let s = sample_slice[i];
if tf_col[s as usize] <= best_threshold_u8 {
bufs.left_buf[l_idx] = s;
l_idx += 1;
} else {
bufs.right_buf[r_idx] = s;
r_idx += 1;
}
}
sample_slice[..l_idx].copy_from_slice(&bufs.left_buf[..l_idx]);
sample_slice[l_idx..].copy_from_slice(&bufs.right_buf[..r_idx]);
let (left_samples, right_samples) = sample_slice.split_at_mut(l_idx);
build_node_multi_sparse(
sparse_y,
x,
left_samples,
&left_y_sums[..n_targets],
&left_y_sum_sqs[..n_targets],
n_total,
n_targets,
n_features_split,
config,
depth + 1,
importances,
bufs,
rng,
);
build_node_multi_sparse(
sparse_y,
x,
right_samples,
&right_y_sums[..n_targets],
&right_y_sum_sqs[..n_targets],
n_total,
n_targets,
n_features_split,
config,
depth + 1,
importances,
bufs,
rng,
);
}
#[allow(clippy::too_many_arguments)]
#[allow(dead_code)]
fn build_node_multi(
y_slice: &mut [f32],
x: &QuantisedStore,
sample_slice: &mut [u32],
y_sums: &[f32],
y_sum_sqs: &[f32],
n_total: usize,
n_targets: usize,
n_features_split: usize,
config: &dyn TreeRegressorConfig,
depth: usize,
importances: &mut [f32],
bufs: &mut TreeBuffers,
rng: &mut SmallRng,
) {
let n = sample_slice.len();
let mut total_parent_var = 0.0f32;
for k in 0..n_targets {
let v = node_variance_f32(y_sums[k], y_sum_sqs[k], n);
bufs.parent_vars[k] = v;
total_parent_var += v;
}
let max_depth_reached = config.max_depth().is_some_and(|d| depth >= d);
if n < 2 * config.min_samples_leaf()
|| total_parent_var < config.min_variance()
|| max_depth_reached
{
return;
}
let n_features = x.n_features;
let k_feats = n_features_split.min(n_features);
for i in 0..k_feats {
let j = rng.random_range(i..n_features);
bufs.feat_buf.swap(i, j);
}
let mut best_score = 0.0f32;
let mut best_feature = usize::MAX;
let mut best_threshold_u8 = 0u8;
let mut best_n_left = 0u32;
for fi_idx in 0..k_feats {
let feat = bufs.feat_buf[fi_idx];
let tf_col = x.get_col(feat);
let (min_bin, max_bin) = bufs.build_histograms(tf_col, sample_slice, y_slice, n_targets);
if min_bin == max_bin {
continue;
}
if config.random_threshold() {
for _ in 0..config.n_thresholds() {
let threshold = rng.random_range(min_bin..max_bin);
evaluate_split_multi(
threshold,
feat,
&bufs.parent_vars,
n,
y_sums,
y_sum_sqs,
&bufs.cum_counts,
&bufs.cum_y_sums,
&bufs.cum_y_sum_sqs,
n_targets,
config.min_samples_leaf() as u32,
&mut best_score,
&mut best_feature,
&mut best_threshold_u8,
&mut best_n_left,
&mut bufs.best_y_sums_l,
&mut bufs.best_y_sum_sqs_l,
);
}
} else {
for threshold in min_bin..max_bin {
evaluate_split_multi(
threshold,
feat,
&bufs.parent_vars,
n,
y_sums,
y_sum_sqs,
&bufs.cum_counts,
&bufs.cum_y_sums,
&bufs.cum_y_sum_sqs,
n_targets,
config.min_samples_leaf() as u32,
&mut best_score,
&mut best_feature,
&mut best_threshold_u8,
&mut best_n_left,
&mut bufs.best_y_sums_l,
&mut bufs.best_y_sum_sqs_l,
);
}
}
}
if best_feature == usize::MAX {
return;
}
let nl = best_n_left as f32;
let nr = (n as u32 - best_n_left) as f32;
let nf = n as f32;
let weight = nf / n_total as f32;
let imp_base = best_feature * n_targets;
for k in 0..n_targets {
let y_sum_l = bufs.best_y_sums_l[k];
let y_sum_sq_l = bufs.best_y_sum_sqs_l[k];
let y_sum_r = y_sums[k] - y_sum_l;
let y_sum_sq_r = y_sum_sqs[k] - y_sum_sq_l;
let mean_l = y_sum_l / nl;
let var_l = f32::max(0.0, y_sum_sq_l / nl - mean_l * mean_l);
let mean_r = y_sum_r / nr;
let var_r = f32::max(0.0, y_sum_sq_r / nr - mean_r * mean_r);
let reduction = bufs.parent_vars[k] - (nl / nf) * var_l - (nr / nf) * var_r;
importances[imp_base + k] += weight * f32::max(0.0, reduction);
}
let mut left_y_sums = [0.0f32; MULTI_OUTPUT_BATCH];
let mut left_y_sum_sqs = [0.0f32; MULTI_OUTPUT_BATCH];
let mut right_y_sums = [0.0f32; MULTI_OUTPUT_BATCH];
let mut right_y_sum_sqs = [0.0f32; MULTI_OUTPUT_BATCH];
left_y_sums[..n_targets].copy_from_slice(&bufs.best_y_sums_l[..n_targets]);
left_y_sum_sqs[..n_targets].copy_from_slice(&bufs.best_y_sum_sqs_l[..n_targets]);
for k in 0..n_targets {
right_y_sums[k] = y_sums[k] - left_y_sums[k];
right_y_sum_sqs[k] = y_sum_sqs[k] - left_y_sum_sqs[k];
}
let tf_col = x.get_col(best_feature);
let mut l_idx = 0usize;
let mut r_idx = 0usize;
for i in 0..n {
let s = sample_slice[i];
let val = tf_col[s as usize];
let src_base = i * n_targets;
if val <= best_threshold_u8 {
bufs.left_buf[l_idx] = s;
let dst = l_idx * n_targets;
bufs.left_y_buf[dst..dst + n_targets]
.copy_from_slice(&y_slice[src_base..src_base + n_targets]);
l_idx += 1;
} else {
bufs.right_buf[r_idx] = s;
let dst = r_idx * n_targets;
bufs.right_y_buf[dst..dst + n_targets]
.copy_from_slice(&y_slice[src_base..src_base + n_targets]);
r_idx += 1;
}
}
sample_slice[..l_idx].copy_from_slice(&bufs.left_buf[..l_idx]);
sample_slice[l_idx..].copy_from_slice(&bufs.right_buf[..r_idx]);
let y_left_len = l_idx * n_targets;
let y_right_len = r_idx * n_targets;
y_slice[..y_left_len].copy_from_slice(&bufs.left_y_buf[..y_left_len]);
y_slice[y_left_len..y_left_len + y_right_len].copy_from_slice(&bufs.right_y_buf[..y_right_len]);
let (left_samples, right_samples) = sample_slice.split_at_mut(l_idx);
let (left_y, right_y) = y_slice.split_at_mut(y_left_len);
build_node_multi(
left_y,
x,
left_samples,
&left_y_sums[..n_targets],
&left_y_sum_sqs[..n_targets],
n_total,
n_targets,
n_features_split,
config,
depth + 1,
importances,
bufs,
rng,
);
build_node_multi(
right_y,
x,
right_samples,
&right_y_sums[..n_targets],
&right_y_sum_sqs[..n_targets],
n_total,
n_targets,
n_features_split,
config,
depth + 1,
importances,
bufs,
rng,
);
}
#[allow(dead_code)]
fn fit_multi_trees(
targets: &[SparseAxis<u32, f32>],
feature_matrix: &QuantisedStore,
n_samples: usize,
config: &dyn TreeRegressorConfig,
seed: usize,
) -> Result<Vec<Vec<f32>>, BixverseErrors> {
let n_features = feature_matrix.n_features;
let n_targets = targets.len();
let n_features_split = resolve_n_features_split(config.n_features_split(), n_features);
let n_sub = if let Some(frac) = config.subsample_frac() {
((n_samples as f32 * frac).round() as usize).max(2 * config.min_samples_leaf())
} else if config.subsample_rate() >= 1.0 {
n_samples
} else {
((n_samples as f32 * config.subsample_rate()).round() as usize)
.max(2 * config.min_samples_leaf())
};
let y_dense = build_y_dense_multi(targets, n_samples)?;
let mut sample_indices: Vec<u32> = vec![0; n_samples];
let mut root_y_buf: Vec<f32> = vec![0.0; n_samples * n_targets];
let mut bufs = TreeBuffers::new(n_features, n_samples, n_targets);
let mut importances = vec![0.0f32; n_features * n_targets];
let mut y_sums_root = vec![0.0f32; n_targets];
let mut y_sum_sqs_root = vec![0.0f32; n_targets];
for tree_idx in 0..config.n_trees() {
let mut rng = SmallRng::seed_from_u64(tree_seed(seed, tree_idx));
let active_len = if n_sub < n_samples {
if config.bootstrap() {
for i in 0..n_sub {
sample_indices[i] = rng.random_range(0..n_samples as u32);
}
n_sub
} else {
init_and_split(&mut sample_indices, n_samples, n_sub, &mut rng);
n_sub
}
} else {
sample_indices
.iter_mut()
.enumerate()
.for_each(|(i, v)| *v = i as u32);
n_samples
};
let active = &mut sample_indices[..active_len];
let root_y = &mut root_y_buf[..active_len * n_targets];
y_sums_root.fill(0.0);
y_sum_sqs_root.fill(0.0);
for i in 0..active_len {
let s = active[i] as usize;
let src_base = s * n_targets;
let dst_base = i * n_targets;
root_y[dst_base..dst_base + n_targets]
.copy_from_slice(&y_dense[src_base..src_base + n_targets]);
for k in 0..n_targets {
let y = root_y[dst_base + k];
y_sums_root[k] += y;
y_sum_sqs_root[k] += y * y;
}
}
build_node_multi(
root_y,
feature_matrix,
active,
&y_sums_root,
&y_sum_sqs_root,
active_len,
n_targets,
n_features_split,
config,
0,
&mut importances,
&mut bufs,
&mut rng,
);
}
Ok(extract_and_normalise_multi_importances(
&importances,
n_features,
n_targets,
))
}
pub fn fit_multi_trees_sparse(
targets: &[SparseAxis<u32, f32>],
feature_matrix: &QuantisedStore,
n_samples: usize,
config: &dyn TreeRegressorConfig,
seed: usize,
) -> Result<Vec<Vec<f32>>, BixverseErrors> {
let n_features = feature_matrix.n_features;
let n_targets = targets.len();
let n_features_split = resolve_n_features_split(config.n_features_split(), n_features);
let n_sub = if let Some(frac) = config.subsample_frac() {
((n_samples as f32 * frac).round() as usize).max(2 * config.min_samples_leaf())
} else if config.subsample_rate() >= 1.0 {
n_samples
} else {
((n_samples as f32 * config.subsample_rate()).round() as usize)
.max(2 * config.min_samples_leaf())
};
let sparse_y = SparseYBatch::from_targets(targets, n_samples)?;
let mut sample_indices: Vec<u32> = vec![0; n_samples];
let mut bufs = TreeBuffers::new_sparse(n_features, n_samples, n_targets);
let mut importances = vec![0.0f32; n_features * n_targets];
let mut y_sums_root = vec![0.0f32; n_targets];
let mut y_sum_sqs_root = vec![0.0f32; n_targets];
for tree_idx in 0..config.n_trees() {
let mut rng = SmallRng::seed_from_u64(tree_seed(seed, tree_idx));
let active_len = if n_sub < n_samples {
if config.bootstrap() {
for i in 0..n_sub {
sample_indices[i] = rng.random_range(0..n_samples as u32);
}
n_sub
} else {
init_and_split(&mut sample_indices, n_samples, n_sub, &mut rng);
n_sub
}
} else {
sample_indices
.iter_mut()
.enumerate()
.for_each(|(i, v)| *v = i as u32);
n_samples
};
let active = &mut sample_indices[..active_len];
y_sums_root.fill(0.0);
y_sum_sqs_root.fill(0.0);
for &s in active.iter() {
let (tgt_idx, tgt_val) = sparse_y.cell_entries(s as usize);
for i in 0..tgt_idx.len() {
let k = tgt_idx[i] as usize;
let y = tgt_val[i];
y_sums_root[k] += y;
y_sum_sqs_root[k] += y * y;
}
}
build_node_multi_sparse(
&sparse_y,
feature_matrix,
active,
&y_sums_root,
&y_sum_sqs_root,
active_len,
n_targets,
n_features_split,
config,
0,
&mut importances,
&mut bufs,
&mut rng,
);
}
Ok(extract_and_normalise_multi_importances(
&importances,
n_features,
n_targets,
))
}
#[derive(Clone, Debug)]
pub struct GbmSplitInfo {
pub feature: usize,
pub threshold: u8,
pub score: f32,
pub n_left: u32,
pub y_sum_left: f32,
pub y_sum_sq_left: f32,
}
pub struct NodeHistograms {
pub counts: Vec<u32>,
pub y_sums: Vec<f32>,
pub y_sum_sqs: Vec<f32>,
pub n_features: usize,
}
impl NodeHistograms {
pub fn new(n_features: usize) -> Self {
let n = n_features * 256;
Self {
counts: vec![0u32; n],
y_sums: vec![0.0f32; n],
y_sum_sqs: vec![0.0f32; n],
n_features,
}
}
pub fn reset(&mut self) {
let n = self.n_features * 256;
self.counts[..n].fill(0);
self.y_sums[..n].fill(0.0);
self.y_sum_sqs[..n].fill(0.0);
}
pub fn build_from_samples(&mut self, x: &QuantisedStore, samples: &[u32], residuals: &[f32]) {
let n = self.n_features * 256;
self.counts[..n].fill(0);
self.y_sums[..n].fill(0.0);
self.y_sum_sqs[..n].fill(0.0);
for f in 0..self.n_features {
let col = x.get_col(f);
let base = f * 256;
for &s in samples {
let bin = col[s as usize] as usize;
let idx = base + bin;
self.counts[idx] += 1;
let r = residuals[s as usize];
self.y_sums[idx] += r;
self.y_sum_sqs[idx] += r * r;
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn find_best_split(
&self,
total_sum: f32,
total_sum_sq: f32,
n_samples: u32,
min_samples_leaf: u32,
n_features_split: usize,
feat_buf: &mut [usize],
rng: &mut SmallRng,
) -> Option<GbmSplitInfo> {
let n_features = self.n_features;
let k_feats = if n_features_split == 0 || n_features_split >= n_features {
n_features
} else {
n_features_split
};
for i in 0..k_feats {
let j = rng.random_range(i..n_features);
feat_buf.swap(i, j);
}
let parent_var = node_variance_f32(total_sum, total_sum_sq, n_samples as usize);
if parent_var < 1e-10 {
return None;
}
let nf = n_samples as f32;
let mut best = GbmSplitInfo {
feature: usize::MAX,
threshold: 0,
score: 0.0,
n_left: 0,
y_sum_left: 0.0,
y_sum_sq_left: 0.0,
};
for fi in 0..k_feats {
let feat = feat_buf[fi];
let base = feat * 256;
let mut cum_count = 0u32;
let mut cum_sum = 0.0f32;
let mut cum_sum_sq = 0.0f32;
for bin in 0..255usize {
cum_count += self.counts[base + bin];
cum_sum += self.y_sums[base + bin];
cum_sum_sq += self.y_sum_sqs[base + bin];
let nl = cum_count;
let nr = n_samples - nl;
if nl < min_samples_leaf || nr < min_samples_leaf {
continue;
}
let nlf = nl as f32;
let nrf = nr as f32;
let mean_l = cum_sum / nlf;
let var_l = f32::max(0.0, cum_sum_sq / nlf - mean_l * mean_l);
let sum_r = total_sum - cum_sum;
let sum_sq_r = total_sum_sq - cum_sum_sq;
let mean_r = sum_r / nrf;
let var_r = f32::max(0.0, sum_sq_r / nrf - mean_r * mean_r);
let score = parent_var - (nlf / nf) * var_l - (nrf / nf) * var_r;
if score > best.score {
best.score = score;
best.feature = feat;
best.threshold = bin as u8;
best.n_left = nl;
best.y_sum_left = cum_sum;
best.y_sum_sq_left = cum_sum_sq;
}
}
}
if best.feature == usize::MAX {
None
} else {
Some(best)
}
}
}
pub struct HistogramPool {
pub histograms: Vec<NodeHistograms>,
free: Vec<usize>,
}
impl HistogramPool {
pub fn new(capacity: usize, n_features: usize) -> Self {
let histograms = (0..capacity)
.map(|_| NodeHistograms::new(n_features))
.collect();
let free = (0..capacity).rev().collect();
Self { histograms, free }
}
pub fn acquire(&mut self) -> usize {
self.free.pop().expect("histogram pool exhausted")
}
fn release(&mut self, idx: usize) {
self.free.push(idx);
}
fn subtract(&mut self, parent: usize, child: usize, out: usize) {
debug_assert_ne!(parent, out);
debug_assert_ne!(child, out);
let n = self.histograms[0].n_features * 256;
for i in 0..n {
let pc = self.histograms[parent].counts[i];
let cc = self.histograms[child].counts[i];
let py = self.histograms[parent].y_sums[i];
let cy = self.histograms[child].y_sums[i];
let pys = self.histograms[parent].y_sum_sqs[i];
let cys = self.histograms[child].y_sum_sqs[i];
self.histograms[out].counts[i] = pc - cc;
self.histograms[out].y_sums[i] = py - cy;
self.histograms[out].y_sum_sqs[i] = pys - cys;
}
}
}
struct SampledGbmScratch {
feat_buf: Vec<usize>,
partitions: PartitionBuffers,
counts: [u32; 256],
y_sums: [f32; 256],
y_sum_sqs: [f32; 256],
}
impl SampledGbmScratch {
fn new(n_features: usize, n_samples: usize) -> Self {
Self {
feat_buf: (0..n_features).collect(),
partitions: PartitionBuffers::new(n_samples),
counts: [0u32; 256],
y_sums: [0.0f32; 256],
y_sum_sqs: [0.0f32; 256],
}
}
#[inline]
fn build_histogram(
&mut self,
tf_col: &[u8],
samples: &[u32],
residuals: &[f32],
) -> (usize, usize) {
if samples.is_empty() {
return (0, 0);
}
let mut min_bin = 255u8;
let mut max_bin = 0u8;
for &s in samples {
let bin = tf_col[s as usize];
if bin < min_bin {
min_bin = bin;
}
if bin > max_bin {
max_bin = bin;
}
}
let min_b = min_bin as usize;
let max_b = max_bin as usize;
self.counts[min_b..=max_b].fill(0);
self.y_sums[min_b..=max_b].fill(0.0);
self.y_sum_sqs[min_b..=max_b].fill(0.0);
for &s in samples {
let bin = tf_col[s as usize] as usize;
let r = residuals[s as usize];
self.counts[bin] += 1;
self.y_sums[bin] += r;
self.y_sum_sqs[bin] += r * r;
}
(min_b, max_b)
}
#[allow(clippy::too_many_arguments)]
fn find_best_threshold(
&self,
min_b: usize,
max_b: usize,
total_sum: f32,
total_sum_sq: f32,
n_samples: u32,
min_samples_leaf: u32,
parent_var: f32,
) -> Option<(u8, f32, u32, f32, f32)> {
if min_b == max_b {
return None;
}
let nf = n_samples as f32;
let mut best_score = 0.0f32;
let mut best_threshold = 0u8;
let mut best_n_left = 0u32;
let mut best_sum_l = 0.0f32;
let mut best_sum_sq_l = 0.0f32;
let mut cum_count = 0u32;
let mut cum_sum = 0.0f32;
let mut cum_sum_sq = 0.0f32;
for bin in min_b..max_b {
cum_count += self.counts[bin];
cum_sum += self.y_sums[bin];
cum_sum_sq += self.y_sum_sqs[bin];
let nl = cum_count;
let nr = n_samples - nl;
if nl < min_samples_leaf || nr < min_samples_leaf {
continue;
}
let nlf = nl as f32;
let nrf = nr as f32;
let mean_l = cum_sum / nlf;
let var_l = f32::max(0.0, cum_sum_sq / nlf - mean_l * mean_l);
let sum_r = total_sum - cum_sum;
let sum_sq_r = total_sum_sq - cum_sum_sq;
let mean_r = sum_r / nrf;
let var_r = f32::max(0.0, sum_sq_r / nrf - mean_r * mean_r);
let score = parent_var - (nlf / nf) * var_l - (nrf / nf) * var_r;
if score > best_score {
best_score = score;
best_threshold = bin as u8;
best_n_left = nl;
best_sum_l = cum_sum;
best_sum_sq_l = cum_sum_sq;
}
}
if best_score > 0.0 {
Some((
best_threshold,
best_score,
best_n_left,
best_sum_l,
best_sum_sq_l,
))
} else {
None
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_gbm_node(
pool: &mut HistogramPool,
node_hist_idx: usize,
x: &QuantisedStore,
residuals: &mut [f32],
train_samples: &mut [u32],
oob_samples: &mut [u32],
y_sum: f32,
y_sum_sq: f32,
n_total_train: usize,
config: &GradientBoostingConfig,
depth: usize,
learning_rate: f32,
importances: &mut [f32],
oob_improvement: &mut f32,
scratch: &mut GbmScratch,
rng: &mut SmallRng,
) {
let n_train = train_samples.len();
if n_train < 2 * config.min_samples_leaf || depth >= config.max_depth {
apply_regression_leaf(
residuals,
train_samples,
oob_samples,
y_sum,
n_train,
learning_rate,
oob_improvement,
);
pool.release(node_hist_idx);
return;
}
let parent_var = node_variance(y_sum, y_sum_sq, n_train);
if parent_var < 1e-10 {
apply_regression_leaf(
residuals,
train_samples,
oob_samples,
y_sum,
n_train,
learning_rate,
oob_improvement,
);
pool.release(node_hist_idx);
return;
}
let split = {
let hist = &pool.histograms[node_hist_idx];
hist.find_best_split(
y_sum,
y_sum_sq,
n_train as u32,
config.min_samples_leaf as u32,
config.n_features_split,
&mut scratch.feat_buf,
rng,
)
};
let split = match split {
Some(s) => s,
None => {
apply_regression_leaf(
residuals,
train_samples,
oob_samples,
y_sum,
n_train,
learning_rate,
oob_improvement,
);
pool.release(node_hist_idx);
return;
}
};
let nl = split.n_left as f32;
let nr = (n_train as u32 - split.n_left) as f32;
let nf = n_train as f32;
let weight = nf / n_total_train as f32;
let mean_l = split.y_sum_left / nl;
let var_l = f32::max(0.0, split.y_sum_sq_left / nl - mean_l * mean_l);
let y_sum_right = y_sum - split.y_sum_left;
let y_sum_sq_right = y_sum_sq - split.y_sum_sq_left;
let mean_r = y_sum_right / nr;
let var_r = f32::max(0.0, y_sum_sq_right / nr - mean_r * mean_r);
let reduction = parent_var - (nl / nf) * var_l - (nr / nf) * var_r;
importances[split.feature] += weight * f32::max(0.0, reduction);
let tf_col = x.get_col(split.feature);
let tl = scratch
.partitions
.partition_train(train_samples, tf_col, split.threshold);
let ol = scratch
.partitions
.partition_oob(oob_samples, tf_col, split.threshold);
let (left_train, right_train) = train_samples.split_at_mut(tl);
let (left_oob, right_oob) = oob_samples.split_at_mut(ol);
let left_is_smaller = tl <= (n_train - tl);
let smaller_idx = pool.acquire();
let larger_idx = pool.acquire();
if left_is_smaller {
pool.histograms[smaller_idx].build_from_samples(x, left_train, residuals);
pool.subtract(node_hist_idx, smaller_idx, larger_idx);
} else {
pool.histograms[smaller_idx].build_from_samples(x, right_train, residuals);
pool.subtract(node_hist_idx, smaller_idx, larger_idx);
}
pool.release(node_hist_idx);
let (left_hist_idx, right_hist_idx) = if left_is_smaller {
(smaller_idx, larger_idx)
} else {
(larger_idx, smaller_idx)
};
build_gbm_node(
pool,
left_hist_idx,
x,
residuals,
left_train,
left_oob,
split.y_sum_left,
split.y_sum_sq_left,
n_total_train,
config,
depth + 1,
learning_rate,
importances,
oob_improvement,
scratch,
rng,
);
build_gbm_node(
pool,
right_hist_idx,
x,
residuals,
right_train,
right_oob,
y_sum_right,
y_sum_sq_right,
n_total_train,
config,
depth + 1,
learning_rate,
importances,
oob_improvement,
scratch,
rng,
);
}
#[allow(clippy::too_many_arguments)]
fn build_gbm_node_sampled(
x: &QuantisedStore,
residuals: &mut [f32],
train_samples: &mut [u32],
oob_samples: &mut [u32],
y_sum: f32,
y_sum_sq: f32,
n_total_train: usize,
n_features_split: usize,
config: &GradientBoostingConfig,
depth: usize,
learning_rate: f32,
importances: &mut [f32],
oob_improvement: &mut f32,
bufs: &mut SampledGbmScratch,
rng: &mut SmallRng,
) {
let n_train = train_samples.len();
let parent_var = node_variance(y_sum, y_sum_sq, n_train);
if n_train < 2 * config.min_samples_leaf || parent_var < 1e-10 || depth >= config.max_depth {
apply_regression_leaf(
residuals,
train_samples,
oob_samples,
y_sum,
n_train,
learning_rate,
oob_improvement,
);
return;
}
let n_features = x.n_features;
let k_feats = sample_features(&mut bufs.feat_buf, n_features, n_features_split, rng);
let mut best_score = 0.0f32;
let mut best_feature = usize::MAX;
let mut best_threshold = 0u8;
let mut best_n_left = 0u32;
let mut best_sum_l = 0.0f32;
let mut best_sum_sq_l = 0.0f32;
for fi in 0..k_feats {
let feat = bufs.feat_buf[fi];
let tf_col = x.get_col(feat);
let (min_b, max_b) = bufs.build_histogram(tf_col, train_samples, residuals);
if let Some((threshold, score, n_left, sum_l, sum_sq_l)) = bufs.find_best_threshold(
min_b,
max_b,
y_sum,
y_sum_sq,
n_train as u32,
config.min_samples_leaf as u32,
parent_var,
) && score > best_score
{
best_score = score;
best_feature = feat;
best_threshold = threshold;
best_n_left = n_left;
best_sum_l = sum_l;
best_sum_sq_l = sum_sq_l;
}
}
if best_feature == usize::MAX {
apply_regression_leaf(
residuals,
train_samples,
oob_samples,
y_sum,
n_train,
learning_rate,
oob_improvement,
);
return;
}
let nl = best_n_left as f32;
let nr = (n_train as u32 - best_n_left) as f32;
let nf = n_train as f32;
let weight = nf / n_total_train as f32;
let mean_l = best_sum_l / nl;
let var_l = f32::max(0.0, best_sum_sq_l / nl - mean_l * mean_l);
let sum_r = y_sum - best_sum_l;
let sum_sq_r = y_sum_sq - best_sum_sq_l;
let mean_r = sum_r / nr;
let var_r = f32::max(0.0, sum_sq_r / nr - mean_r * mean_r);
let reduction = parent_var - (nl / nf) * var_l - (nr / nf) * var_r;
importances[best_feature] += weight * f32::max(0.0, reduction);
let tf_col = x.get_col(best_feature);
let tl = bufs
.partitions
.partition_train(train_samples, tf_col, best_threshold);
let ol = bufs
.partitions
.partition_oob(oob_samples, tf_col, best_threshold);
let (left_train, right_train) = train_samples.split_at_mut(tl);
let (left_oob, right_oob) = oob_samples.split_at_mut(ol);
let right_sum = y_sum - best_sum_l;
let right_sum_sq = y_sum_sq - best_sum_sq_l;
build_gbm_node_sampled(
x,
residuals,
left_train,
left_oob,
best_sum_l,
best_sum_sq_l,
n_total_train,
n_features_split,
config,
depth + 1,
learning_rate,
importances,
oob_improvement,
bufs,
rng,
);
build_gbm_node_sampled(
x,
residuals,
right_train,
right_oob,
right_sum,
right_sum_sq,
n_total_train,
n_features_split,
config,
depth + 1,
learning_rate,
importances,
oob_improvement,
bufs,
rng,
);
}
pub fn fit_grnboost2_full_hist(
target: &SparseAxis<u32, f32>,
feature_matrix: &QuantisedStore,
n_samples: usize,
config: &GradientBoostingConfig,
seed: usize,
) -> Result<Vec<f32>, BixverseErrors> {
let n_features = feature_matrix.n_features;
let pool_capacity = 2 * config.max_depth + 3;
let mut residuals = vec![0.0f32; n_samples];
let (indices, values) = target.get_indices_data_2()?;
for (i, &idx) in indices.iter().enumerate() {
residuals[idx] = values[i];
}
let n_train = ((n_samples as f32 * config.subsample_rate).round() as usize)
.max(2 * config.min_samples_leaf);
let n_oob = n_samples - n_train;
let mut sample_indices: Vec<u32> = (0..n_samples as u32).collect();
let mut importances = vec![0.0f32; n_features];
let mut early_stop = EarlyStopMonitor::new(config.early_stop_window);
let mut scratch = GbmScratch::new(n_features, n_samples);
let mut pool = HistogramPool::new(pool_capacity, n_features);
for tree_idx in 0..config.n_trees_max {
let mut rng = SmallRng::seed_from_u64(tree_seed(seed, tree_idx));
init_and_split(&mut sample_indices, n_samples, n_train, &mut rng);
let (train, oob) = sample_indices.split_at_mut(n_train);
let root_idx = pool.acquire();
pool.histograms[root_idx].build_from_samples(feature_matrix, train, &residuals);
let mut y_sum = 0.0f32;
let mut y_sum_sq = 0.0f32;
for &s in train.iter() {
let r = residuals[s as usize];
y_sum += r;
y_sum_sq += r * r;
}
let mut oob_improvement = 0.0f32;
build_gbm_node(
&mut pool,
root_idx,
feature_matrix,
&mut residuals,
train,
oob,
y_sum,
y_sum_sq,
n_train,
config,
0,
config.learning_rate,
&mut importances,
&mut oob_improvement,
&mut scratch,
&mut rng,
);
let oob_mse = if n_oob > 0 {
oob_improvement / n_oob as f32
} else {
0.0
};
if early_stop.push(oob_mse) {
break;
}
}
normalise_importances(&mut importances);
Ok(importances)
}
pub fn fit_grnboost2_sampled(
target: &SparseAxis<u32, f32>,
feature_matrix: &QuantisedStore,
n_samples: usize,
config: &GradientBoostingConfig,
seed: usize,
) -> Result<Vec<f32>, BixverseErrors> {
let n_features = feature_matrix.n_features;
let n_features_split = resolve_n_features_split(config.n_features_split, n_features);
let mut residuals = vec![0.0f32; n_samples];
let (indices, values) = target.get_indices_data_2()?;
for (i, &idx) in indices.iter().enumerate() {
residuals[idx] = values[i];
}
let n_train = ((n_samples as f32 * config.subsample_rate).round() as usize)
.max(2 * config.min_samples_leaf);
let n_oob = n_samples - n_train;
let mut sample_indices: Vec<u32> = (0..n_samples as u32).collect();
let mut importances = vec![0.0f32; n_features];
let mut early_stop = EarlyStopMonitor::new(config.early_stop_window);
let mut bufs = SampledGbmScratch::new(n_features, n_samples);
for tree_idx in 0..config.n_trees_max {
let mut rng = SmallRng::seed_from_u64(tree_seed(seed, tree_idx));
init_and_split(&mut sample_indices, n_samples, n_train, &mut rng);
let (train, oob) = sample_indices.split_at_mut(n_train);
let mut y_sum = 0.0f32;
let mut y_sum_sq = 0.0f32;
for &s in train.iter() {
let r = residuals[s as usize];
y_sum += r;
y_sum_sq += r * r;
}
let mut oob_improvement = 0.0f32;
build_gbm_node_sampled(
feature_matrix,
&mut residuals,
train,
oob,
y_sum,
y_sum_sq,
n_train,
n_features_split,
config,
0,
config.learning_rate,
&mut importances,
&mut oob_improvement,
&mut bufs,
&mut rng,
);
let oob_mse = if n_oob > 0 {
oob_improvement / n_oob as f32
} else {
0.0
};
if early_stop.push(oob_mse) {
break;
}
}
normalise_importances(&mut importances);
Ok(importances)
}
pub fn fit_grnboost2_sparse(
target: &SparseAxis<u32, f32>,
feature_matrix: &QuantisedStore,
n_samples: usize,
config: &GradientBoostingConfig,
seed: usize,
) -> Result<Vec<f32>, BixverseErrors> {
if n_samples >= GBM_SUBTRACTION_CELL_THRESHOLD {
fit_grnboost2_full_hist(target, feature_matrix, n_samples, config, seed)
} else {
fit_grnboost2_sampled(target, feature_matrix, n_samples, config, seed)
}
}
#[derive(Clone, Debug, Default)]
pub enum GeneBatchStrategy {
#[default]
Random,
Correlated {
n_comp: usize,
n_cells_subsample: usize,
},
}
pub fn parse_gene_batch_strategy(
s: &str,
n_comp: usize,
n_cells_subsample: usize,
) -> Option<GeneBatchStrategy> {
match s.to_lowercase().as_str() {
"random" => Some(GeneBatchStrategy::Random),
"correlated" => Some(GeneBatchStrategy::Correlated {
n_comp,
n_cells_subsample,
}),
_ => None,
}
}
fn chunk_into_batches(genes: &[usize], batch_size: usize) -> Vec<Vec<usize>> {
genes.chunks(batch_size).map(|c| c.to_vec()).collect()
}
pub(crate) fn group_batches_for_io(
batches: &[Vec<usize>],
max_genes: usize,
) -> Vec<(usize, usize)> {
let mut groups = Vec::new();
let mut start = 0usize;
let mut count = 0usize;
for (i, b) in batches.iter().enumerate() {
if count > 0 && count + b.len() > max_genes {
groups.push((start, i));
start = i;
count = 0;
}
count += b.len();
}
if start < batches.len() {
groups.push((start, batches.len()));
}
groups
}
pub fn batch_genes_random(gene_indices: &[usize], seed: usize) -> Vec<usize> {
let mut indices = gene_indices.to_vec();
let mut rng = SmallRng::seed_from_u64(seed as u64);
for i in (1..indices.len()).rev() {
let j = rng.random_range(0..=i);
indices.swap(i, j);
}
indices
}
pub fn subsample_cells(cell_indices: &[usize], n_target: usize, seed: usize) -> Vec<usize> {
if cell_indices.len() <= n_target {
return cell_indices.to_vec();
}
let mut indices = cell_indices.to_vec();
let mut rng = SmallRng::seed_from_u64(seed as u64);
for i in 0..n_target {
let j = rng.random_range(i..indices.len());
indices.swap(i, j);
}
indices.truncate(n_target);
indices
}
#[allow(clippy::too_many_arguments)]
fn batch_genes_correlated<S: SingleCellReading>(
reader: &S,
gene_indices: &[usize],
cell_indices: &[usize],
batch_size: usize,
n_components: usize,
n_cells_subsample: usize,
seed: usize,
verbose: usize,
) -> Result<Vec<Vec<usize>>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let n_genes = gene_indices.len();
let n_centroids = n_genes.div_ceil(batch_size);
let sub_cells = subsample_cells(cell_indices, n_cells_subsample, seed);
if verbosity.normal_verbosity() {
println!(
"Computing gene loadings: {} genes, {} subsampled cells, {} components",
n_genes,
sub_cells.len(),
n_components
);
}
let pca_params = SingleCellPcaParams::new(true, true, true, false, 1e4);
let (_, loadings, _, _) = pca_on_sc_streaming(
reader,
&sub_cells,
gene_indices,
n_components,
&pca_params,
None,
seed,
false,
SCENIC_GENE_CHUNK_SIZE,
verbose,
)?;
let dim = loadings.ncols();
let mut gene_loadings = vec![0.0f32; n_genes * dim];
for g in 0..n_genes {
for c in 0..dim {
gene_loadings[g * dim + c] = loadings[(g, c)];
}
}
if verbosity.normal_verbosity() {
println!("Clustering {} genes into {} groups", n_genes, n_centroids);
}
let k_means_params = KMeansParamsWrappers::new(50, None, None);
let centroids = train_centroids(
&gene_loadings,
dim,
n_genes,
n_centroids,
&Dist::SquaredEuclidean,
Some(k_means_params.get_data()),
seed,
verbosity.detailed_verbosity(),
)?;
let centroid_norms: Vec<f32> = (0..n_centroids)
.map(|i| {
let c = ¢roids[i * dim..(i + 1) * dim];
f32::dot_simd(c, c)
})
.collect();
let data_norms: Vec<f32> = (0..n_genes)
.map(|i| {
let v = &gene_loadings[i * dim..(i + 1) * dim];
f32::dot_simd(v, v)
})
.collect();
let assignments = assign_all_parallel(
&gene_loadings,
&data_norms,
dim,
n_genes,
¢roids,
¢roid_norms,
n_centroids,
&Dist::SquaredEuclidean,
);
let mut clusters: Vec<Vec<usize>> = vec![Vec::new(); n_centroids];
for (i, &cluster_id) in assignments.iter().enumerate() {
clusters[cluster_id].push(gene_indices[i]);
}
let mut rng = SmallRng::seed_from_u64(seed.wrapping_add(1) as u64);
let mut batches: Vec<Vec<usize>> = Vec::with_capacity(n_centroids);
for cluster in &mut clusters {
if cluster.is_empty() {
continue;
}
for i in (1..cluster.len()).rev() {
let j = rng.random_range(0..=i);
cluster.swap(i, j);
}
for chunk in cluster.chunks(batch_size) {
batches.push(chunk.to_vec());
}
}
Ok(batches)
}
pub fn batch_genes<S: SingleCellReading>(
reader: &S,
gene_indices: &[usize],
cell_indices: &[usize],
batch_size: usize,
strategy: &GeneBatchStrategy,
seed: usize,
verbose: usize,
) -> Result<Vec<Vec<usize>>, BixverseErrors> {
match strategy {
GeneBatchStrategy::Random => Ok(chunk_into_batches(
&batch_genes_random(gene_indices, seed),
batch_size,
)),
GeneBatchStrategy::Correlated {
n_comp,
n_cells_subsample,
} => {
if gene_indices.len() <= batch_size {
return Ok(chunk_into_batches(
&batch_genes_random(gene_indices, seed),
batch_size,
));
}
batch_genes_correlated(
reader,
gene_indices,
cell_indices,
batch_size,
*n_comp,
(*n_cells_subsample).min(cell_indices.len()),
seed,
verbose,
)
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_scenic_multi_output<S: SingleCellReading>(
reader: &S,
cell_set: &IndexSet<u32>,
cell_indices: &[usize],
gene_indices: &[usize],
tf_data: &QuantisedStore,
n_cells: usize,
n_tfs: usize,
n_genes: usize,
scenic_params: &ScenicParams,
seed: usize,
verbose: usize,
start_total: Instant,
) -> Result<Mat<f32>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let n_multi_output = scenic_params
.gene_batch_size
.unwrap_or(MULTI_OUTPUT_BATCH)
.min(MULTI_OUTPUT_BATCH);
let strategy = parse_gene_batch_strategy(
&scenic_params.gene_batch_strategy,
scenic_params.n_pcs,
scenic_params.n_subsample,
)
.unwrap_or(GeneBatchStrategy::Random);
let batches = batch_genes(
reader,
gene_indices,
cell_indices,
n_multi_output,
&strategy,
seed,
verbose,
)?;
let ordered_genes: Vec<usize> = batches.iter().flatten().copied().collect();
let gene_id_to_pos: FxHashMap<usize, usize> = gene_indices
.iter()
.enumerate()
.map(|(pos, &gid)| (gid, pos))
.collect();
let start_gene_read = Instant::now();
let mut all_sparse_cols: Vec<SparseAxis<u32, f32>> = Vec::with_capacity(n_genes);
for (iter, chunk) in ordered_genes.chunks(SCENIC_GENE_CHUNK_SIZE).enumerate() {
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(chunk)?;
gene_chunks.par_iter_mut().for_each(|c| {
c.filter_selected_cells(cell_set);
});
for gc in gene_chunks.iter() {
all_sparse_cols.push(gc.to_sparse_axis(n_cells));
}
if verbosity.detailed_verbosity() {
println!(
" Read gene chunk {}/{} ({} genes)",
iter + 1,
ordered_genes.len().div_ceil(SCENIC_GENE_CHUNK_SIZE),
all_sparse_cols.len(),
);
}
}
if verbosity.normal_verbosity() {
println!(
"Read and filtered {} target genes in {:.2?}",
n_genes,
start_gene_read.elapsed()
);
}
let mut col_batches: Vec<&[SparseAxis<u32, f32>]> = Vec::with_capacity(batches.len());
let mut offset = 0usize;
for b in &batches {
col_batches.push(&all_sparse_cols[offset..offset + b.len()]);
offset += b.len();
}
let total_batches = col_batches.len();
let config: &dyn TreeRegressorConfig = match &scenic_params.regression_learner {
RegressionLearner::ExtraTrees(cfg) => cfg,
RegressionLearner::RandomForest(cfg) => cfg,
RegressionLearner::GradientBoosting(_) => unreachable!(),
};
let learner_name = match &scenic_params.regression_learner {
RegressionLearner::ExtraTrees(_) => "ExtraTrees",
RegressionLearner::RandomForest(_) => "RandomForest",
RegressionLearner::GradientBoosting(_) => unreachable!(),
};
if verbosity.normal_verbosity() {
println!(
"Running SCENIC ({}) on {} genes ({} TFs, {} cells, {} batches of up to {})",
learner_name, n_genes, n_tfs, n_cells, total_batches, n_multi_output,
);
}
let start_fit = Instant::now();
let batches_done = AtomicUsize::new(0);
let batch_results: Vec<(usize, Vec<Vec<f32>>)> = (0..total_batches)
.into_par_iter()
.map(|batch_idx| {
let batch_seed = seed.wrapping_add(batch_idx.wrapping_mul(2654435761));
let imp = fit_multi_trees_sparse(
col_batches[batch_idx],
tf_data,
n_cells,
config,
batch_seed,
)?;
if verbosity.normal_verbosity() {
let done = batches_done.fetch_add(1, Ordering::Relaxed) + 1;
let pct = done * 100 / total_batches;
let prev_pct = (done - 1) * 100 / total_batches;
if pct / 10 > prev_pct / 10 || done == total_batches {
println!(
" Progress: {}% ({}/{} batches, {:.2?} elapsed)",
pct,
done,
total_batches,
start_fit.elapsed()
);
}
}
Ok((batch_idx, imp))
})
.collect::<Result<Vec<_>, BixverseErrors>>()?;
let mut importance_scores: Vec<Vec<f32>> = vec![Vec::new(); n_genes];
for (batch_idx, imp_vecs) in batch_results {
let batch_gene_ids = &batches[batch_idx];
for (local_idx, imp) in imp_vecs.into_iter().enumerate() {
let gene_id = batch_gene_ids[local_idx];
let original_pos = gene_id_to_pos[&gene_id];
importance_scores[original_pos] = imp;
}
}
if verbosity.normal_verbosity() {
println!(
"SCENIC ({}) GRN inference complete in {:.2?}",
learner_name,
start_total.elapsed()
);
}
Ok(Mat::from_fn(n_genes, n_tfs, |i, j| {
if j < importance_scores[i].len() {
importance_scores[i][j]
} else {
0.0
}
}))
}
#[allow(clippy::too_many_arguments)]
fn run_scenic_gbm<S: SingleCellReading>(
reader: &S,
cell_set: &IndexSet<u32>,
gene_indices: &[usize],
tf_data: &QuantisedStore,
n_cells: usize,
n_tfs: usize,
n_genes: usize,
config: &GradientBoostingConfig,
seed: usize,
verbose: usize,
start_total: Instant,
) -> Result<Mat<f32>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let start_gene_read = Instant::now();
let mut all_sparse_cols: Vec<SparseAxis<u32, f32>> = Vec::with_capacity(n_genes);
if verbosity.normal_verbosity() {
println!(
"Running GRNBoost2 on {} genes ({} TFs, {} cells, {} I/O chunks)",
n_genes.separate_with_underscores(),
n_tfs.separate_with_underscores(),
n_cells.separate_with_underscores(),
gene_indices.len().div_ceil(SCENIC_GENE_CHUNK_SIZE),
);
}
for (iter, chunk) in gene_indices.chunks(SCENIC_GENE_CHUNK_SIZE).enumerate() {
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(chunk)?;
gene_chunks.par_iter_mut().for_each(|c| {
c.filter_selected_cells(cell_set);
});
for gc in &gene_chunks {
all_sparse_cols.push(gc.to_sparse_axis(n_cells));
}
if verbosity.detailed_verbosity() {
println!(
" Read gene chunk {}/{} ({} genes)",
iter + 1,
gene_indices.len().div_ceil(SCENIC_GENE_CHUNK_SIZE),
all_sparse_cols.len(),
);
}
}
if verbosity.normal_verbosity() {
println!(
"Read and filtered {} target genes in {:.2?}",
n_genes,
start_gene_read.elapsed()
);
println!(
"Running GRNBoost2 on {} genes ({} TFs, {} cells)",
n_genes, n_tfs, n_cells,
);
}
let start_fit = Instant::now();
let genes_done = AtomicUsize::new(0);
let importance_scores: Vec<Vec<f32>> = all_sparse_cols
.par_iter()
.enumerate()
.map(|(gene_idx, target)| {
let gene_seed = seed.wrapping_add(gene_idx.wrapping_mul(2654435761));
let imp = fit_grnboost2_sparse(target, tf_data, n_cells, config, gene_seed)?;
if verbosity.normal_verbosity() {
let done = genes_done.fetch_add(1, Ordering::Relaxed) + 1;
let pct = done * 100 / n_genes;
let prev_pct = (done - 1) * 100 / n_genes;
if pct / 10 > prev_pct / 10 || done == n_genes {
println!(
" Progress: {}% ({}/{} genes, {:.2?} elapsed)",
pct,
done,
n_genes,
start_fit.elapsed()
);
}
}
Ok(imp)
})
.collect::<Result<Vec<_>, BixverseErrors>>()?;
if verbosity.normal_verbosity() {
println!(
"GRNBoost2 GRN inference complete in {:.2?}",
start_total.elapsed()
);
}
Ok(Mat::from_fn(n_genes, n_tfs, |i, j| {
if j < importance_scores[i].len() {
importance_scores[i][j]
} else {
0.0
}
}))
}
#[allow(clippy::too_many_arguments)]
fn run_scenic_multi_output_streaming<S: SingleCellReading>(
reader: &S,
cell_set: &IndexSet<u32>,
cell_indices: &[usize],
gene_indices: &[usize],
tf_data: &QuantisedStore,
n_cells: usize,
n_tfs: usize,
n_genes: usize,
scenic_params: &ScenicParams,
seed: usize,
verbose: usize,
start_total: Instant,
) -> Result<Mat<f32>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let n_multi_output = scenic_params
.gene_batch_size
.unwrap_or(MULTI_OUTPUT_BATCH)
.min(MULTI_OUTPUT_BATCH);
let strategy = parse_gene_batch_strategy(
&scenic_params.gene_batch_strategy,
scenic_params.n_pcs,
scenic_params.n_subsample,
)
.unwrap_or(GeneBatchStrategy::Random);
let batches = batch_genes(
reader,
gene_indices,
cell_indices,
n_multi_output,
&strategy,
seed,
verbose,
)?;
let io_groups = group_batches_for_io(&batches, SCENIC_GENE_CHUNK_SIZE);
let gene_id_to_pos: FxHashMap<usize, usize> = gene_indices
.iter()
.enumerate()
.map(|(pos, &gid)| (gid, pos))
.collect();
let config: &dyn TreeRegressorConfig = match &scenic_params.regression_learner {
RegressionLearner::ExtraTrees(cfg) => cfg,
RegressionLearner::RandomForest(cfg) => cfg,
RegressionLearner::GradientBoosting(_) => unreachable!(),
};
let learner_name = match &scenic_params.regression_learner {
RegressionLearner::ExtraTrees(_) => "ExtraTrees",
RegressionLearner::RandomForest(_) => "RandomForest",
RegressionLearner::GradientBoosting(_) => unreachable!(),
};
let total_io_chunks = io_groups.len();
let mut importance_scores: Vec<Vec<f32>> = vec![Vec::new(); n_genes];
let mut global_batch_offset: usize = 0;
let mut genes_processed: usize = 0;
if verbosity.normal_verbosity() {
println!(
"Running SCENIC ({}, streaming) on {} genes ({} TFs, {} cells, {} I/O chunks, batches of {})",
learner_name,
n_genes.separate_with_underscores(),
n_tfs.separate_with_underscores(),
n_cells.separate_with_underscores(),
total_io_chunks,
n_multi_output,
);
}
for (chunk_idx, &(g_start, g_end)) in io_groups.iter().enumerate() {
let start_chunk = Instant::now();
let group = &batches[g_start..g_end];
let io_chunk: Vec<usize> = group.iter().flatten().copied().collect();
let start_io = Instant::now();
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(&io_chunk)?;
gene_chunks.par_iter_mut().for_each(|c| {
c.filter_selected_cells(cell_set);
});
let sparse_columns: Vec<SparseAxis<u32, f32>> = gene_chunks
.iter()
.map(|c| c.to_sparse_axis(n_cells))
.collect();
drop(gene_chunks);
if verbosity.normal_verbosity() {
println!(
" Chunk {}/{}: loaded and filtered {} genes in {:.2?}",
chunk_idx + 1,
total_io_chunks,
io_chunk.len(),
start_io.elapsed()
);
}
let mut col_offsets = Vec::with_capacity(group.len() + 1);
let mut off = 0usize;
col_offsets.push(0);
for b in group {
off += b.len();
col_offsets.push(off);
}
let n_batches_this_chunk = group.len();
let start_fit = Instant::now();
let batches_done = AtomicUsize::new(0);
let batch_results: Vec<(usize, Vec<Vec<f32>>)> = (0..n_batches_this_chunk)
.into_par_iter()
.map(|local_batch_idx| {
let batch_seed = seed
.wrapping_add((global_batch_offset + local_batch_idx).wrapping_mul(2654435761));
let cols =
&sparse_columns[col_offsets[local_batch_idx]..col_offsets[local_batch_idx + 1]];
let imp = fit_multi_trees_sparse(cols, tf_data, n_cells, config, batch_seed)?;
if verbosity.detailed_verbosity() && n_batches_this_chunk >= 4 {
let done = batches_done.fetch_add(1, Ordering::Relaxed) + 1;
let pct = done * 100 / n_batches_this_chunk;
let prev_pct = (done - 1) * 100 / n_batches_this_chunk;
if [25, 50, 75, 100].iter().any(|&q| prev_pct < q && pct >= q) {
println!(
" Chunk {}: ~{}% of batches done ({}/{}, {:.2?} elapsed)",
chunk_idx + 1,
pct,
done,
n_batches_this_chunk,
start_fit.elapsed()
);
}
}
Ok((local_batch_idx, imp))
})
.collect::<Result<Vec<_>, BixverseErrors>>()?;
for (local_batch_idx, imp_vecs) in batch_results {
let batch_gene_ids = &group[local_batch_idx];
for (local_idx, imp) in imp_vecs.into_iter().enumerate() {
let gene_id = batch_gene_ids[local_idx];
let original_pos = gene_id_to_pos[&gene_id];
importance_scores[original_pos] = imp;
}
}
global_batch_offset += n_batches_this_chunk;
genes_processed += io_chunk.len();
if verbosity.normal_verbosity() {
println!(
" Chunk {}/{}: {}/{} genes done in {:.2?} (fit: {:.2?})",
chunk_idx + 1,
total_io_chunks,
genes_processed,
n_genes,
start_chunk.elapsed(),
start_fit.elapsed()
);
}
}
if verbosity.normal_verbosity() {
println!(
"SCENIC ({}) GRN inference (streaming) complete in {:.2?}",
learner_name,
start_total.elapsed()
);
}
Ok(Mat::from_fn(n_genes, n_tfs, |i, j| {
if j < importance_scores[i].len() {
importance_scores[i][j]
} else {
0.0
}
}))
}
#[allow(clippy::too_many_arguments)]
fn run_scenic_gbm_streaming<S: SingleCellReading>(
reader: &S,
cell_set: &IndexSet<u32>,
gene_indices: &[usize],
tf_data: &QuantisedStore,
n_cells: usize,
n_tfs: usize,
n_genes: usize,
config: &GradientBoostingConfig,
seed: usize,
verbose: usize,
start_total: Instant,
) -> Result<Mat<f32>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let total_io_chunks = gene_indices.len().div_ceil(SCENIC_GENE_CHUNK_SIZE);
let mut importance_scores: Vec<Vec<f32>> = vec![Vec::new(); n_genes];
let mut global_gene_offset: usize = 0;
if verbosity.normal_verbosity() {
println!(
"Running GRNBoost2 (streaming) on {} genes ({} TFs, {} cells, {} I/O chunks)",
n_genes.separate_with_underscores(),
n_tfs.separate_with_underscores(),
n_cells.separate_with_underscores(),
total_io_chunks,
);
}
for (chunk_idx, io_chunk) in gene_indices.chunks(SCENIC_GENE_CHUNK_SIZE).enumerate() {
let start_chunk = Instant::now();
let start_io = Instant::now();
let mut gene_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(io_chunk)?;
gene_chunks.par_iter_mut().for_each(|c| {
c.filter_selected_cells(cell_set);
});
let sparse_columns: Vec<SparseAxis<u32, f32>> = gene_chunks
.iter()
.map(|c| c.to_sparse_axis(n_cells))
.collect();
drop(gene_chunks);
if verbosity.normal_verbosity() {
println!(
" Chunk {}/{}: loaded and filtered {} genes in {:.2?}",
chunk_idx + 1,
total_io_chunks,
io_chunk.len(),
start_io.elapsed()
);
}
let start_fit = Instant::now();
let genes_done = AtomicUsize::new(0);
let n_genes_this_chunk = sparse_columns.len();
let chunk_results: Vec<Vec<f32>> = sparse_columns
.par_iter()
.enumerate()
.map(|(local_idx, target)| {
let gene_seed =
seed.wrapping_add((global_gene_offset + local_idx).wrapping_mul(2654435761));
let imp = fit_grnboost2_sparse(target, tf_data, n_cells, config, gene_seed)?;
if verbosity.detailed_verbosity() && n_genes_this_chunk >= 4 {
let done = genes_done.fetch_add(1, Ordering::Relaxed) + 1;
let pct = done * 100 / n_genes_this_chunk;
let prev_pct = (done - 1) * 100 / n_genes_this_chunk;
if [25, 50, 75, 100].iter().any(|&q| prev_pct < q && pct >= q) {
println!(
" Chunk {}: ~{}% of genes done ({}/{}, {:.2?} elapsed)",
chunk_idx + 1,
pct,
done,
n_genes_this_chunk,
start_fit.elapsed()
);
}
}
Ok(imp)
})
.collect::<Result<Vec<_>, BixverseErrors>>()?;
for (local_idx, imp) in chunk_results.into_iter().enumerate() {
importance_scores[global_gene_offset + local_idx] = imp;
}
global_gene_offset += n_genes_this_chunk;
if verbosity.normal_verbosity() {
println!(
" Chunk {}/{}: {}/{} genes done in {:.2?} (fit: {:.2?})",
chunk_idx + 1,
total_io_chunks,
global_gene_offset,
n_genes,
start_chunk.elapsed(),
start_fit.elapsed()
);
}
}
if verbosity.normal_verbosity() {
println!(
"GRNBoost2 GRN inference (streaming) complete in {:.2?}",
start_total.elapsed()
);
}
Ok(Mat::from_fn(n_genes, n_tfs, |i, j| {
if j < importance_scores[i].len() {
importance_scores[i][j]
} else {
0.0
}
}))
}
pub struct ScenicParams {
pub min_counts: usize,
pub min_cells: f32,
pub regression_learner: RegressionLearner,
pub gene_batch_strategy: String,
pub gene_batch_size: Option<usize>,
pub n_pcs: usize,
pub n_subsample: usize,
}
impl Default for ScenicParams {
fn default() -> Self {
Self {
min_counts: 50,
min_cells: 0.03,
regression_learner: RegressionLearner::RandomForest(RandomForestConfig::default()),
gene_batch_strategy: "correlated".to_string(),
gene_batch_size: None,
n_pcs: 50,
n_subsample: 100_000,
}
}
}
pub(crate) struct ScenicSetup<'a, S: SingleCellReading> {
pub reader: &'a S,
pub cell_set: IndexSet<u32>,
pub tf_data: QuantisedStore,
pub n_cells: usize,
pub n_tfs: usize,
pub start_total: Instant,
}
pub(crate) fn scenic_common_setup<'a, S: SingleCellReading>(
reader: &'a S,
cell_indices: &[usize],
tf_indices: &[usize],
verbose: usize,
) -> Result<ScenicSetup<'a, S>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let start_total = Instant::now();
let cell_set: IndexSet<u32> = cell_indices.iter().map(|&x| x as u32).collect();
let n_cells = cell_set.len();
let start_reading = Instant::now();
let mut tf_chunks: Vec<CscGeneChunk> = reader.read_gene_parallel(tf_indices)?;
tf_chunks.par_iter_mut().for_each(|chunk| {
chunk.filter_selected_cells(&cell_set);
});
let tf_csc: CompressedSparseData2<u16, f32> =
from_gene_chunks::<u16>(tf_chunks, &DataLayerReturn::Norm, n_cells)?;
let tf_data = QuantisedStore::from_csc(&tf_csc, n_cells)?;
drop(tf_csc);
let n_tfs = tf_data.n_features;
if verbosity.normal_verbosity() {
println!(
"Loaded, filtered and quantised TF data (n: {}) in: {:.2?}",
n_tfs.separate_with_underscores(),
start_reading.elapsed()
);
}
Ok(ScenicSetup {
reader,
cell_set,
tf_data,
n_cells,
n_tfs,
start_total,
})
}
pub fn scenic_gene_filter<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
scenic_params: &ScenicParams,
verbose: usize,
) -> Result<Vec<usize>, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let total_genes = reader.get_header().total_genes;
let all_gene_indices: Vec<usize> = (0..total_genes).collect();
let cell_set: IndexSet<u32> = cell_indices.iter().map(|&x| x as u32).collect();
let n_cells = cell_indices.len();
let mut passing = Vec::new();
for (iter, chunk) in all_gene_indices.chunks(SCENIC_GENE_CHUNK_SIZE).enumerate() {
let mut gene_chunks = reader.read_gene_parallel(chunk)?;
gene_chunks.par_iter_mut().for_each(|c| {
c.filter_selected_cells(&cell_set);
});
for gene in &gene_chunks {
let total_counts: u32 = gene.data_raw.iter().sum();
let expressed_fraction = gene.nnz as f32 / n_cells as f32;
if total_counts >= scenic_params.min_counts as u32
&& expressed_fraction >= scenic_params.min_cells
{
passing.push(gene.original_index);
}
}
if verbosity.normal_verbosity() {
println!(
"Processed chunk {} out of {} for SCENIC inclusion criteria.",
iter + 1,
total_genes.div_ceil(SCENIC_GENE_CHUNK_SIZE)
);
}
}
Ok(passing)
}
pub fn run_scenic_grn<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
gene_indices: &[usize],
tf_indices: &[usize],
scenic_params: &ScenicParams,
seed: usize,
verbose: usize,
) -> Result<Mat<f32>, BixverseErrors> {
let setup = scenic_common_setup(reader, cell_indices, tf_indices, verbose)?;
let n_genes = gene_indices.len();
match &scenic_params.regression_learner {
RegressionLearner::GradientBoosting(gbm_config) => run_scenic_gbm(
setup.reader,
&setup.cell_set,
gene_indices,
&setup.tf_data,
setup.n_cells,
setup.n_tfs,
n_genes,
gbm_config,
seed,
verbose,
setup.start_total,
),
_ => run_scenic_multi_output(
setup.reader,
&setup.cell_set,
cell_indices,
gene_indices,
&setup.tf_data,
setup.n_cells,
setup.n_tfs,
n_genes,
scenic_params,
seed,
verbose,
setup.start_total,
),
}
}
pub fn run_scenic_grn_streaming<S: SingleCellReading>(
reader: &S,
cell_indices: &[usize],
gene_indices: &[usize],
tf_indices: &[usize],
scenic_params: &ScenicParams,
seed: usize,
verbose: usize,
) -> Result<Mat<f32>, BixverseErrors> {
let setup = scenic_common_setup(reader, cell_indices, tf_indices, verbose)?;
let n_genes = gene_indices.len();
match &scenic_params.regression_learner {
RegressionLearner::GradientBoosting(gbm_config) => run_scenic_gbm_streaming(
setup.reader,
&setup.cell_set,
gene_indices,
&setup.tf_data,
setup.n_cells,
setup.n_tfs,
n_genes,
gbm_config,
seed,
verbose,
setup.start_total,
),
_ => run_scenic_multi_output_streaming(
setup.reader,
&setup.cell_set,
cell_indices,
gene_indices,
&setup.tf_data,
setup.n_cells,
setup.n_tfs,
n_genes,
scenic_params,
seed,
verbose,
setup.start_total,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_variance_basic() {
let (sum, sum_sq) = (6.0f32, 14.0f32);
let v = node_variance_f32(sum, sum_sq, 3);
assert!((v - 2.0 / 3.0).abs() < 1e-6, "got {v}");
}
#[test]
fn node_variance_uniform() {
let (sum, sum_sq) = (9.0f32, 27.0f32);
let v = node_variance_f32(sum, sum_sq, 3);
assert_eq!(v, 0.0);
}
#[test]
fn histogram_build_multi_target() {
let n_targets = 2;
let n_features = 1;
let n_samples = 4;
let mut bufs = TreeBuffers::new(n_features, n_samples, n_targets);
let tf_col: Vec<u8> = vec![0, 0, 0, 10, 10, 0];
let sample_slice: Vec<u32> = vec![0, 1, 3, 4];
let y_slice: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
bufs.build_histograms(&tf_col, &sample_slice, &y_slice, n_targets);
assert_eq!(bufs.counts[0], 2);
assert!((bufs.y_sums[0] - 4.0).abs() < 1e-10);
assert!((bufs.y_sums[1] - 6.0).abs() < 1e-10);
assert!((bufs.y_sum_sqs[0] - 10.0).abs() < 1e-10);
assert!((bufs.y_sum_sqs[1] - 20.0).abs() < 1e-10);
assert_eq!(bufs.counts[10], 2);
assert!((bufs.y_sums[10 * n_targets] - 12.0).abs() < 1e-10);
assert!((bufs.y_sums[10 * n_targets + 1] - 14.0).abs() < 1e-10);
assert!((bufs.y_sum_sqs[10 * n_targets] - 74.0).abs() < 1e-10);
assert!((bufs.y_sum_sqs[10 * n_targets + 1] - 100.0).abs() < 1e-10);
assert_eq!(bufs.cum_counts[10], 4);
assert!((bufs.cum_y_sums[10 * n_targets] - 16.0).abs() < 1e-10);
assert!((bufs.cum_y_sums[10 * n_targets + 1] - 20.0).abs() < 1e-10);
}
#[test]
fn sparse_y_batch_construction() {
let offsets = vec![0u32, 1, 2, 4, 4];
let target_indices = vec![0u8, 1, 0, 1];
let values = vec![1.0f32, 2.0, 3.0, 4.0];
let sy = SparseYBatch {
offsets,
target_indices,
values,
};
let (ti, tv) = sy.cell_entries(0);
assert_eq!(ti, &[0]);
assert_eq!(tv, &[1.0]);
let (ti, tv) = sy.cell_entries(1);
assert_eq!(ti, &[1]);
assert_eq!(tv, &[2.0]);
let (ti, tv) = sy.cell_entries(2);
assert_eq!(ti, &[0, 1]);
assert_eq!(tv, &[3.0, 4.0]);
let (ti, tv) = sy.cell_entries(3);
assert!(ti.is_empty());
assert!(tv.is_empty());
}
#[test]
fn sparse_histogram_matches_dense() {
let n_targets = 2;
let n_features = 1;
let n_samples = 6;
let tf_col: Vec<u8> = vec![0, 0, 10, 10, 0, 10];
let sample_slice: Vec<u32> = vec![0, 1, 2, 3, 4, 5];
let y_dense: Vec<f32> = vec![1.0, 2.0, 0.0, 0.0, 3.0, 4.0, 0.0, 0.0, 5.0, 6.0, 7.0, 8.0];
let sparse_y = SparseYBatch {
offsets: vec![0, 2, 2, 4, 4, 6, 8],
target_indices: vec![0, 1, 0, 1, 0, 1, 0, 1],
values: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
};
let mut bufs_dense = TreeBuffers::new(n_features, n_samples, n_targets);
let mut bufs_sparse = TreeBuffers::new_sparse(n_features, n_samples, n_targets);
let (min_d, max_d) =
bufs_dense.build_histograms(&tf_col, &sample_slice, &y_dense, n_targets);
let (min_s, max_s) =
bufs_sparse.build_histograms_sparse(&tf_col, &sample_slice, &sparse_y, n_targets);
assert_eq!(min_d, min_s);
assert_eq!(max_d, max_s);
assert_eq!(
&bufs_dense.counts[min_d..=max_d],
&bufs_sparse.counts[min_s..=max_s]
);
assert_eq!(
&bufs_dense.cum_counts[min_d..=max_d],
&bufs_sparse.cum_counts[min_s..=max_s]
);
let start_idx = min_d * n_targets;
let end_idx = (max_d + 1) * n_targets;
for i in start_idx..end_idx {
assert!(
(bufs_dense.y_sums[i] - bufs_sparse.y_sums[i]).abs() < 1e-10,
"y_sums mismatch at {i}: dense={} sparse={}",
bufs_dense.y_sums[i],
bufs_sparse.y_sums[i]
);
assert!(
(bufs_dense.y_sum_sqs[i] - bufs_sparse.y_sum_sqs[i]).abs() < 1e-10,
"y_sum_sqs mismatch at {i}"
);
assert!(
(bufs_dense.cum_y_sums[i] - bufs_sparse.cum_y_sums[i]).abs() < 1e-10,
"cum_y_sums mismatch at {i}"
);
assert!(
(bufs_dense.cum_y_sum_sqs[i] - bufs_sparse.cum_y_sum_sqs[i]).abs() < 1e-10,
"cum_y_sum_sqs mismatch at {i}"
);
}
}
#[test]
fn evaluate_split_basic() {
let n_targets = 1;
let mut cum_counts = [0u32; 256];
let mut cum_y_sums = vec![0.0f32; 256];
let mut cum_y_sum_sqs = vec![0.0f32; 256];
cum_counts[0] = 2;
cum_y_sums[0] = 4.0;
cum_y_sum_sqs[0] = 10.0;
for b in 1..256 {
cum_counts[b] = cum_counts[b - 1];
cum_y_sums[b] = cum_y_sums[b - 1];
cum_y_sum_sqs[b] = cum_y_sum_sqs[b - 1];
if b == 10 {
cum_counts[b] += 2;
cum_y_sums[b] += 22.0;
cum_y_sum_sqs[b] += 244.0;
}
}
let parent_vars = vec![node_variance_f32(26.0, 254.0, 4)];
let y_sums_total = vec![26.0f32];
let y_sum_sqs_total = vec![254.0f32];
let mut best_score = 0.0f32;
let mut best_feature = usize::MAX;
let mut best_threshold = 0u8;
let mut best_n_left = 0u32;
let mut best_ys_l = vec![0.0f32; 1];
let mut best_yss_l = vec![0.0f32; 1];
evaluate_split_multi(
0, 0, &parent_vars,
4,
&y_sums_total,
&y_sum_sqs_total,
&cum_counts,
&cum_y_sums,
&cum_y_sum_sqs,
n_targets,
1, &mut best_score,
&mut best_feature,
&mut best_threshold,
&mut best_n_left,
&mut best_ys_l,
&mut best_yss_l,
);
assert_eq!(best_feature, 0);
assert_eq!(best_threshold, 0);
assert_eq!(best_n_left, 2);
assert!((best_score - 20.25).abs() < 1e-10, "got {}", best_score);
}
#[test]
fn batch_genes_random_is_permutation() {
let genes: Vec<usize> = (0..100).collect();
let shuffled = batch_genes_random(&genes, 42);
assert_eq!(shuffled.len(), genes.len());
let mut sorted = shuffled.clone();
sorted.sort();
assert_eq!(sorted, genes);
assert_ne!(shuffled, genes);
}
#[test]
fn batch_genes_random_deterministic() {
let genes: Vec<usize> = (0..100).collect();
let a = batch_genes_random(&genes, 42);
let b = batch_genes_random(&genes, 42);
assert_eq!(a, b);
}
#[test]
fn subsample_cells_all_when_small() {
let cells: Vec<usize> = (0..50).collect();
let sub = subsample_cells(&cells, 100, 0);
assert_eq!(sub.len(), 50);
let mut sorted = sub.clone();
sorted.sort();
assert_eq!(sorted, cells);
}
#[test]
fn subsample_cells_correct_count() {
let cells: Vec<usize> = (0..1000).collect();
let sub = subsample_cells(&cells, 100, 42);
assert_eq!(sub.len(), 100);
for &c in &sub {
assert!(c < 1000);
}
let mut sorted = sub.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), 100);
}
#[test]
fn node_hist_build_from_samples() {
let data = vec![
0, 10, 10, 20, 5, 5, 15, 15, ];
let store = QuantisedStore::from_raw(data, 4, 2);
let residuals = vec![1.0, 2.0, 3.0, 4.0];
let samples: Vec<u32> = vec![0, 1, 2, 3];
let mut hist = NodeHistograms::new(2);
hist.build_from_samples(&store, &samples, &residuals);
assert_eq!(hist.counts[0], 1);
assert_eq!(hist.counts[10], 2);
assert_eq!(hist.counts[20], 1);
assert!((hist.y_sums[0] - 1.0).abs() < 1e-10);
assert!((hist.y_sums[10] - 5.0).abs() < 1e-10);
assert!((hist.y_sums[20] - 4.0).abs() < 1e-10);
assert!((hist.y_sum_sqs[10] - 13.0).abs() < 1e-10);
assert_eq!(hist.counts[256 + 5], 2);
assert_eq!(hist.counts[256 + 15], 2);
assert!((hist.y_sums[256 + 5] - 3.0).abs() < 1e-10);
assert!((hist.y_sums[256 + 15] - 7.0).abs() < 1e-10);
}
#[test]
fn node_hist_build_subset() {
let data = vec![0, 10, 10, 20, 5, 5, 15, 15];
let store = QuantisedStore::from_raw(data, 4, 2);
let residuals = vec![1.0, 2.0, 3.0, 4.0];
let samples: Vec<u32> = vec![0, 2];
let mut hist = NodeHistograms::new(2);
hist.build_from_samples(&store, &samples, &residuals);
assert_eq!(hist.counts[0], 1);
assert_eq!(hist.counts[10], 1);
assert_eq!(hist.counts[20], 0);
assert!((hist.y_sums[0] - 1.0).abs() < 1e-10);
assert!((hist.y_sums[10] - 3.0).abs() < 1e-10);
}
#[test]
fn histogram_subtraction_matches_complement() {
let data = vec![0, 10, 10, 20, 5, 5, 15, 15];
let store = QuantisedStore::from_raw(data, 4, 2);
let residuals = vec![1.0, 2.0, 3.0, 4.0];
let mut parent = NodeHistograms::new(2);
parent.build_from_samples(&store, &[0, 1, 2, 3], &residuals);
let mut child = NodeHistograms::new(2);
child.build_from_samples(&store, &[0, 2], &residuals);
let mut complement_direct = NodeHistograms::new(2);
complement_direct.build_from_samples(&store, &[1, 3], &residuals);
let mut pool = HistogramPool::new(3, 2);
pool.histograms[0].counts.copy_from_slice(&parent.counts);
pool.histograms[0].y_sums.copy_from_slice(&parent.y_sums);
pool.histograms[0]
.y_sum_sqs
.copy_from_slice(&parent.y_sum_sqs);
pool.histograms[1].counts.copy_from_slice(&child.counts);
pool.histograms[1].y_sums.copy_from_slice(&child.y_sums);
pool.histograms[1]
.y_sum_sqs
.copy_from_slice(&child.y_sum_sqs);
pool.subtract(0, 1, 2);
let result = &pool.histograms[2];
let n = 2 * 256;
for i in 0..n {
assert_eq!(
result.counts[i], complement_direct.counts[i],
"counts mismatch at {i}"
);
assert!(
(result.y_sums[i] - complement_direct.y_sums[i]).abs() < 1e-6,
"y_sums mismatch at {i}: {} vs {}",
result.y_sums[i],
complement_direct.y_sums[i]
);
assert!(
(result.y_sum_sqs[i] - complement_direct.y_sum_sqs[i]).abs() < 1e-6,
"y_sum_sqs mismatch at {i}"
);
}
}
#[test]
fn find_split_obvious_partition() {
let data: Vec<u8> = vec![0, 0, 0, 0, 100, 100, 100, 100];
let store = QuantisedStore::from_raw(data, 8, 1);
let residuals = vec![1.0, 1.0, 1.0, 1.0, 10.0, 10.0, 10.0, 10.0];
let samples: Vec<u32> = (0..8).collect();
let mut hist = NodeHistograms::new(1);
hist.build_from_samples(&store, &samples, &residuals);
let total_sum: f32 = residuals.iter().sum();
let total_sum_sq: f32 = residuals.iter().map(|&r| r * r).sum();
let mut feat_buf: Vec<usize> = vec![0];
let mut rng = SmallRng::seed_from_u64(42);
let split = hist
.find_best_split(total_sum, total_sum_sq, 8, 1, 0, &mut feat_buf, &mut rng)
.expect("should find a split");
assert_eq!(split.feature, 0);
assert!(split.threshold < 100, "threshold={}", split.threshold);
assert_eq!(split.n_left, 4);
assert!((split.y_sum_left - 4.0).abs() < 1e-6);
}
#[test]
fn find_split_respects_min_samples_leaf() {
let data: Vec<u8> = vec![0, 0, 0, 100];
let store = QuantisedStore::from_raw(data, 4, 1);
let residuals = vec![1.0, 2.0, 3.0, 100.0];
let samples: Vec<u32> = vec![0, 1, 2, 3];
let mut hist = NodeHistograms::new(1);
hist.build_from_samples(&store, &samples, &residuals);
let total_sum: f32 = residuals.iter().sum();
let total_sum_sq: f32 = residuals.iter().map(|&r| r * r).sum();
let mut feat_buf: Vec<usize> = vec![0];
let mut rng = SmallRng::seed_from_u64(0);
let split = hist.find_best_split(total_sum, total_sum_sq, 4, 3, 0, &mut feat_buf, &mut rng);
assert!(split.is_none(), "should not find a valid split");
}
#[test]
fn find_split_no_variance() {
let data: Vec<u8> = vec![0, 50, 100, 200];
let store = QuantisedStore::from_raw(data, 4, 1);
let residuals = vec![5.0, 5.0, 5.0, 5.0];
let samples: Vec<u32> = vec![0, 1, 2, 3];
let mut hist = NodeHistograms::new(1);
hist.build_from_samples(&store, &samples, &residuals);
let mut feat_buf: Vec<usize> = vec![0];
let mut rng = SmallRng::seed_from_u64(0);
let split = hist.find_best_split(20.0, 100.0, 4, 1, 0, &mut feat_buf, &mut rng);
assert!(split.is_none());
}
#[test]
fn apply_leaf_updates_residuals() {
let mut residuals = vec![10.0, 20.0, 30.0, 40.0];
let train: Vec<u32> = vec![0, 1];
let oob: Vec<u32> = vec![2, 3];
let mut improvement = 0.0f32;
apply_regression_leaf(&mut residuals, &train, &oob, 30.0, 2, 0.1, &mut improvement);
assert!((residuals[0] - 8.5).abs() < 1e-6);
assert!((residuals[1] - 18.5).abs() < 1e-6);
assert!((residuals[2] - 28.5).abs() < 1e-6);
assert!((residuals[3] - 38.5).abs() < 1e-6);
assert!((improvement - 205.5).abs() < 1e-4, "got {}", improvement);
}
#[test]
fn fit_grnboost2_strong_signal() {
let n_cells = 200;
let n_features = 3;
let mut rng = SmallRng::seed_from_u64(123);
let mut data = vec![0u8; n_features * n_cells];
for f in 0..n_features {
for c in 0..n_cells {
data[f * n_cells + c] = rng.random_range(0..=255u8);
}
}
let store = QuantisedStore::from_raw(data, n_cells, n_features);
let tf0_col = store.get_col(0);
let mut target_indices: Vec<usize> = Vec::new();
let mut target_values: Vec<f32> = Vec::new();
for c in 0..n_cells {
let val = 2.0 * tf0_col[c] as f32 + rng.random_range(-5.0..5.0f32);
if val.abs() > 0.01 {
target_indices.push(c);
target_values.push(val);
}
}
let target = SparseAxis {
indices: target_indices.to_vec(),
data: Vec::new(),
data_2: Some(target_values),
cs_type: CompressedSparseFormat::Csc,
len: n_cells,
};
let config = GradientBoostingConfig {
n_trees_max: 200,
learning_rate: 0.05,
max_depth: 3,
min_samples_leaf: 5,
early_stop_window: 20,
subsample_rate: 0.9,
n_features_split: 0,
};
let imp = fit_grnboost2_sparse(&target, &store, n_cells, &config, 42).unwrap();
assert_eq!(imp.len(), n_features);
assert!(
imp[0] > imp[1] && imp[0] > imp[2],
"TF0 importance ({}) should dominate: {:?}",
imp[0],
imp
);
let sum: f32 = imp.iter().sum();
assert!((sum - 1.0).abs() < 1e-6, "sum={}", sum);
}
#[test]
fn fit_grnboost2_early_stopping() {
let n_cells = 100;
let n_features = 2;
let mut rng = SmallRng::seed_from_u64(99);
let mut data = vec![0u8; n_features * n_cells];
for i in 0..data.len() {
data[i] = rng.random_range(0..=255u8);
}
let store = QuantisedStore::from_raw(data, n_cells, n_features);
let target = SparseAxis {
indices: Vec::new(),
data: Vec::new(),
data_2: Some(Vec::new()),
cs_type: CompressedSparseFormat::Csc,
len: n_cells,
};
let config = GradientBoostingConfig {
n_trees_max: 500,
learning_rate: 0.01,
max_depth: 3,
min_samples_leaf: 5,
early_stop_window: 10,
subsample_rate: 0.9,
n_features_split: 0,
};
let imp = fit_grnboost2_sparse(&target, &store, n_cells, &config, 0).unwrap();
let total: f32 = imp.iter().sum();
assert!(
total < 1e-6,
"expected near-zero importance for zero target, got {}",
total
);
}
#[test]
fn fit_grnboost2_deterministic() {
let n_cells = 100;
let n_features = 2;
let mut rng = SmallRng::seed_from_u64(7);
let mut data = vec![0u8; n_features * n_cells];
for i in 0..data.len() {
data[i] = rng.random_range(0..=255u8);
}
let store = QuantisedStore::from_raw(data, n_cells, n_features);
let mut t_idx: Vec<u32> = Vec::new();
let mut t_val: Vec<f32> = Vec::new();
let col = store.get_col(0);
for c in 0..n_cells {
let v = col[c] as f32;
if v > 0.0 {
t_idx.push(c as u32);
t_val.push(v);
}
}
let target = SparseAxis {
indices: t_idx.iter().map(|&i| i as usize).collect(),
data: Vec::new(),
data_2: Some(t_val),
cs_type: CompressedSparseFormat::Csc,
len: n_cells,
};
let config = GradientBoostingConfig::default();
let a = fit_grnboost2_sparse(&target, &store, n_cells, &config, 42).unwrap();
let b = fit_grnboost2_sparse(&target, &store, n_cells, &config, 42).unwrap();
assert_eq!(a, b, "same seed should produce identical results");
}
#[test]
fn pool_acquire_release_cycle() {
let mut pool = HistogramPool::new(3, 1);
let a = pool.acquire();
let b = pool.acquire();
let c = pool.acquire();
assert_ne!(a, b);
assert_ne!(b, c);
pool.release(b);
let d = pool.acquire();
assert_eq!(d, b, "released slot should be reused");
}
#[test]
#[should_panic(expected = "histogram pool exhausted")]
fn pool_exhaustion_panics() {
let mut pool = HistogramPool::new(2, 1);
let _ = pool.acquire();
let _ = pool.acquire();
let _ = pool.acquire(); }
}