use crate::hdf5_io::*;
use crate::sparse_io::*;
use crate::zarr_io::finalize_output;
use clap::Args;
use log::info;
use rand::rngs::SmallRng;
use rand::seq::SliceRandom;
use rand::SeedableRng;
use rustc_hash::FxHashMap as HashMap;
use crate::handlers::explore::{off_screen, PlanArgs};
use crate::interactive::ui::Tui;
#[path = "split_plan.rs"]
mod plan;
#[derive(Args, Debug)]
pub struct SplitArgs {
pub data_file: Box<str>,
#[arg(long, conflicts_with = "folds")]
pub test_frac: Option<f64>,
#[arg(long, conflicts_with = "test_frac")]
pub folds: Option<usize>,
#[arg(long, value_name = "FILE", conflicts_with = "groups")]
pub coord: Option<Box<str>>,
#[arg(long, value_name = "I,J", value_delimiter = ',', requires = "coord")]
pub coord_columns: Option<Vec<usize>>,
#[arg(long, default_value_t = 8, requires = "coord")]
pub grid: usize,
#[arg(long, value_name = "TABLE")]
pub groups: Option<Box<str>>,
#[arg(long, default_value_t = 42)]
pub seed: u64,
#[arg(long, value_enum, default_value = "zarr")]
pub backend: SparseIoBackend,
#[arg(short, long)]
pub output: Box<str>,
#[arg(long = "no-zip", default_value_t = true, action = clap::ArgAction::SetFalse)]
pub zip: bool,
#[command(flatten)]
pub view: PlanArgs,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Choice {
test_frac: Option<f64>,
folds: Option<usize>,
grid: usize,
seed: u64,
}
pub fn run_split(args: &SplitArgs) -> anyhow::Result<()> {
let session = args.view.session("split")?;
let (backend_in, file_in) = resolve_backend_file(&args.data_file, None)?;
let data = open_sparse_matrix(&file_in, &backend_in)?;
let n_columns = data
.num_columns()
.ok_or_else(|| anyhow::anyhow!("backend has no `ncol`"))?;
let column_names = data.column_names()?;
let mut choice = Choice {
test_frac: args.test_frac,
folds: args.folds,
grid: args.grid,
seed: args.seed,
};
let grouping = Grouping::read(args, &column_names)?;
let mut tui = session.as_ref().map(Tui::new);
if let Some(tui) = tui.as_mut() {
match plan::choose(tui, args, &grouping, column_names.len(), choice)? {
Some(c) => choice = c,
None => {
info!("split: cancelled; nothing written");
return Ok(());
}
}
}
let (_, splits) = split_columns(&grouping, n_columns, choice)?;
off_screen(tui.as_mut(), || {
write_splits(args, &*data, &column_names, &splits, choice)
})
}
type Splits = Vec<(Vec<usize>, Vec<usize>)>;
enum Grouping {
Cells,
Coords {
path: Box<str>,
of_column: Vec<(f64, f64)>,
},
Table(Vec<Vec<usize>>),
}
impl Grouping {
fn read(args: &SplitArgs, column_names: &[Box<str>]) -> anyhow::Result<Self> {
Ok(match (args.coord.as_deref(), args.groups.as_deref()) {
(Some(path), _) => {
let coord_columns = args.coord_columns.clone().unwrap_or_else(|| vec![1, 2]);
Grouping::Coords {
path: path.into(),
of_column: coords_of_columns(path, &coord_columns, column_names)?,
}
}
(_, Some(path)) => Grouping::Table(column_groups_from_table(path, column_names)?),
_ => Grouping::Cells,
})
}
fn column_groups(&self, n_columns: usize, grid: usize) -> anyhow::Result<Vec<Vec<usize>>> {
Ok(match self {
Grouping::Coords { path, of_column } => column_groups_from_grid(path, grid, of_column)?,
Grouping::Table(groups) => groups.clone(),
Grouping::Cells => (0..n_columns).map(|i| vec![i]).collect(),
})
}
}
fn split_columns(
grouping: &Grouping,
n_columns: usize,
choice: Choice,
) -> anyhow::Result<(usize, Splits)> {
let column_groups = grouping.column_groups(n_columns, choice.grid)?;
anyhow::ensure!(
column_groups.len() >= 2,
"only {} group(s) to split: every cell falls in the same tile or group, \
so there is no way to hold any of them out. Raise --grid, or check that \
--groups / --coord really vary across cells.",
column_groups.len()
);
let splits = partition_groups(
column_groups.len(),
choice.test_frac,
choice.folds,
choice.seed,
)?
.into_iter()
.map(|(train_groups, test_groups)| {
(
columns_in_groups(&column_groups, &train_groups),
columns_in_groups(&column_groups, &test_groups),
)
})
.collect();
Ok((column_groups.len(), splits))
}
fn half_name(args: &SplitArgs, choice: Choice, fold: usize, half: &str) -> String {
match choice.folds {
Some(_) => format!("{}.fold{fold}.{half}", args.output),
None => format!("{}.{half}", args.output),
}
}
fn write_splits(
args: &SplitArgs,
data: &dyn SparseIo<IndexIter = Vec<usize>>,
column_names: &[Box<str>],
splits: &Splits,
choice: Choice,
) -> anyhow::Result<()> {
let n_columns = column_names.len();
let row_names = data.row_names()?;
let names = (row_names.as_slice(), column_names);
for fold in 0..splits.len() {
for half in ["train", "test"] {
super::ensure_output_is_no_input(
&[&args.data_file],
&half_name(args, choice, fold, half),
args.backend.clone(),
args.zip,
)?;
}
}
for (fold, (train_columns, test_columns)) in splits.iter().enumerate() {
anyhow::ensure!(
train_columns.len() + test_columns.len() == n_columns,
"split lost cells: {} train + {} test != {n_columns}",
train_columns.len(),
test_columns.len()
);
let tag = |half: &str| half_name(args, choice, fold, half);
write_half(data, names, train_columns, &tag("train"), args)?;
write_half(data, names, test_columns, &tag("test"), args)?;
info!(
"fold {fold}: {} train cells, {} test cells",
train_columns.len(),
test_columns.len()
);
}
Ok(())
}
fn columns_in_groups(column_groups: &[Vec<usize>], picked: &[usize]) -> Vec<usize> {
let mut columns: Vec<usize> = picked
.iter()
.flat_map(|&g| column_groups[g].iter().copied())
.collect();
columns.sort_unstable();
columns
}
fn coords_of_columns(
path: &str,
coord_columns: &[usize],
column_names: &[Box<str>],
) -> anyhow::Result<Vec<(f64, f64)>> {
anyhow::ensure!(
coord_columns.len() == 2,
"--coord-columns needs exactly two indices; got {}",
coord_columns.len()
);
let coords = read_coords(path, coord_columns)?;
let of_column: Vec<(f64, f64)> = column_names
.iter()
.filter_map(|name| coords.get(name.as_ref()).copied())
.collect();
let n_missing = column_names.len() - of_column.len();
anyhow::ensure!(
n_missing == 0,
"{path}: {n_missing} of {} cells have no coordinate",
column_names.len()
);
Ok(of_column)
}
fn column_groups_from_grid(
path: &str,
tiles_per_axis: usize,
coord_of_column: &[(f64, f64)],
) -> anyhow::Result<Vec<Vec<usize>>> {
anyhow::ensure!(tiles_per_axis >= 2, "--grid must be at least 2");
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &(x, y) in coord_of_column {
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
let span = |lo: f64, hi: f64| if hi > lo { hi - lo } else { 1.0 };
let (width, height) = (span(x0, x1), span(y0, y1));
let mut group_of_tile: HashMap<usize, usize> = HashMap::default();
let mut column_groups: Vec<Vec<usize>> = Vec::new();
for (column, &(x, y)) in coord_of_column.iter().enumerate() {
let tx = (((x - x0) / width) * tiles_per_axis as f64)
.floor()
.clamp(0.0, (tiles_per_axis - 1) as f64) as usize;
let ty = (((y - y0) / height) * tiles_per_axis as f64)
.floor()
.clamp(0.0, (tiles_per_axis - 1) as f64) as usize;
let tile = ty * tiles_per_axis + tx;
let next = column_groups.len();
let group = *group_of_tile.entry(tile).or_insert(next);
if group == next {
column_groups.push(Vec::new());
}
column_groups[group].push(column);
}
info!(
"spatial split: {} cells in {} non-empty tiles of a \
{tiles_per_axis}x{tiles_per_axis} grid ({path})",
coord_of_column.len(),
column_groups.len()
);
Ok(column_groups)
}
fn read_coords(path: &str, cols: &[usize]) -> anyhow::Result<HashMap<Box<str>, (f64, f64)>> {
let (i, j) = (cols[0], cols[1]);
let mut out: HashMap<Box<str>, (f64, f64)> = HashMap::default();
if path.ends_with(".parquet") {
let names = legume_numeric::matrix::parquet::read_parquet_string_column(path, 0)?;
let mat =
<nalgebra::DMatrix<f32> as legume_numeric::matrix::traits::IoOps>::from_parquet(path)?;
let n_file_columns = mat.mat.ncols() + 1;
anyhow::ensure!(
(1..n_file_columns).contains(&i) && (1..n_file_columns).contains(&j),
"{path}: --coord-columns {i},{j} are not among its value columns 1 to {} \
(column 0 is the cell name)",
n_file_columns - 1
);
for (r, name) in names.iter().enumerate() {
let (x, y) = (
f64::from(mat.mat[(r, i - 1)]),
f64::from(mat.mat[(r, j - 1)]),
);
if x.is_finite() && y.is_finite() {
out.insert(name.clone(), (x, y));
}
}
anyhow::ensure!(!out.is_empty(), "{path}: no coordinate rows parsed");
return Ok(out);
}
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading coordinates {path}: {e}"))?;
for (lineno, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let f: Vec<&str> = if line.contains('\t') {
line.split('\t').collect()
} else if line.contains(',') {
line.split(',').collect()
} else {
line.split_whitespace().collect()
};
let max = i.max(j);
anyhow::ensure!(
f.len() > max,
"{path}:{}: {} columns, need index {max}",
lineno + 1,
f.len()
);
let (Ok(x), Ok(y)) = (f[i].trim().parse::<f64>(), f[j].trim().parse::<f64>()) else {
continue;
};
if x.is_finite() && y.is_finite() {
out.insert(f[0].trim().into(), (x, y));
}
}
anyhow::ensure!(!out.is_empty(), "{path}: no coordinate rows parsed");
Ok(out)
}
fn column_groups_from_table(
path: &str,
column_names: &[Box<str>],
) -> anyhow::Result<Vec<Vec<usize>>> {
let labels = super::group_table::column_labels(path, column_names)?;
let (_, column_groups, _) = super::group_table::groups_of(&labels);
info!(
"grouped split: {} cells in {} groups from {path}",
column_names.len(),
column_groups.len()
);
Ok(column_groups)
}
fn write_half(
data: &dyn SparseIo<IndexIter = Vec<usize>>,
(row_names, all_column_names): (&[Box<str>], &[Box<str>]),
selected_columns: &[usize],
output: &str,
args: &SplitArgs,
) -> anyhow::Result<()> {
let kept_column_names: Vec<Box<str>> = selected_columns
.iter()
.map(|&c| all_column_names[c].clone())
.collect();
let (effective_output, backend_out, file_out) =
super::prepare_output(output, args.backend.clone(), args.zip)?;
info!("Streaming {} columns to {output}", selected_columns.len());
let (n_rows, n_cols, nnz) = crate::column_subset::stream_column_selection(
data,
selected_columns,
None,
row_names,
&kept_column_names,
file_out.as_ref(),
&backend_out,
)?;
let final_path = finalize_output(&file_out, &effective_output)?;
info!("wrote {final_path} ({n_rows} genes x {n_cols} cells, {nnz} non-zeros)");
Ok(())
}
fn partition_groups(
n_groups: usize,
test_frac: Option<f64>,
folds: Option<usize>,
seed: u64,
) -> anyhow::Result<Vec<(Vec<usize>, Vec<usize>)>> {
let mut shuffled_groups: Vec<usize> = (0..n_groups).collect();
let mut rng = SmallRng::seed_from_u64(seed);
shuffled_groups.shuffle(&mut rng);
match (test_frac, folds) {
(Some(f), _) => {
anyhow::ensure!(
f > 0.0 && f < 1.0,
"--test-frac must be in (0, 1); got {f}. A percentage goes in as e.g. 0.2"
);
let n_test = ((f * n_groups as f64).round() as usize).clamp(1, n_groups - 1);
let (test_groups, train_groups) = shuffled_groups.split_at(n_test);
Ok(vec![(train_groups.to_vec(), test_groups.to_vec())])
}
(_, Some(k)) => {
anyhow::ensure!(k >= 2, "--folds must be at least 2");
anyhow::ensure!(
k <= n_groups,
"--folds {k} exceeds the {n_groups} groups available to split"
);
Ok((0..k)
.map(|f| {
let take = |in_test: bool| -> Vec<usize> {
shuffled_groups
.iter()
.enumerate()
.filter(|(i, _)| (i % k == f) == in_test)
.map(|(_, &g)| g)
.collect()
};
(take(false), take(true))
})
.collect())
}
(None, None) => anyhow::bail!("specify one of --test-frac or --folds"),
}
}
#[cfg(all(test, feature = "ndarray"))]
mod tests;