Skip to main content

somatize_study/partition/
mod.rs

1//! Which of the schemes a cut is, when that is decided by data rather than in
2//! the source.
3//!
4//! Each scheme is a type of its own with its own `folds`, in its own file. When
5//! you know which one you want, say it and the enum is not in the way:
6//!
7//! ```
8//! use somatize_study::{KFold, Samples};
9//!
10//! let folds = KFold { k: 5, shuffle: None }.folds(&Samples::of(100))?;
11//! # Ok::<(), somatize_study::PartitionError>(())
12//! ```
13//!
14//! [`Partition`] is for the other case, which is the common one at this level:
15//! the scheme arrives from a Python call, a configuration file or the record of
16//! a trial. There, the type is not known when this compiles.
17//!
18//! # Why the family is an enum and each scheme a struct
19//!
20//! The dispatch is static either way — `Self::KFold(cut) => cut.folds(samples)`
21//! resolves to a concrete function with no vtable. What the enum adds is
22//! everything that happens at the edges:
23//!
24//! - **The name is structural, not a convention.** A cut is part of a cache key
25//!   (CU13), and with a trait the name would be supplied by the implementor:
26//!   two that collide, or one that changes between versions, gives the wrong
27//!   fold back **in silence**. Here it is derived, and there is a test that
28//!   two cuts which differ are written differently.
29//! - **It comes back from a record.** To deserialize you must name the type
30//!   when it compiles, and the type is inside the JSON. Without the enum that
31//!   is a `match` on strings — the same `match`, minus the compiler checking
32//!   it is complete — written once per consumer instead of once here.
33//! - **A new scheme stops compiling in three places, and they are listed.**
34//!   With a trait it compiles, and what you forgot is the registration.
35//!
36//! What is deliberately **not** here is an `Explicit { folds }` escape hatch for
37//! a scheme nobody has written yet: it costs three lines the day someone needs
38//! it, and the indices hash themselves.
39
40mod dealing;
41mod grouped;
42mod kfold;
43mod stratified;
44mod stratified_grouped;
45mod time_series;
46
47pub use grouped::Grouped;
48pub use kfold::KFold;
49pub use stratified::Stratified;
50pub use stratified_grouped::StratifiedGrouped;
51pub use time_series::TimeSeries;
52
53use crate::Samples;
54use std::fmt;
55
56/// One cut: who trains and who is held out. Both sides in ascending order —
57/// shuffling decides **who** is in each fold, never the order they are listed
58/// in, so a fold reads the same whatever produced it.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Fold {
61    /// The indices to train on.
62    pub train: Vec<usize>,
63    /// The indices held out.
64    pub test: Vec<usize>,
65}
66
67/// Whichever of the schemes a cut is.
68///
69/// ```
70/// use somatize_study::{Partition, Samples, Stratified};
71///
72/// let cut: Partition = Stratified { k: 5, shuffle: None }.into();
73/// assert_eq!(cut.to_string(), "stratified:5");
74/// # Ok::<(), somatize_study::PartitionError>(())
75/// ```
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub enum Partition {
78    /// [`KFold`]: the plain cut.
79    KFold(KFold),
80    /// [`Stratified`]: every class keeps its share.
81    Stratified(Stratified),
82    /// [`Grouped`]: a group never splits.
83    Grouped(Grouped),
84    /// [`StratifiedGrouped`]: both, as far as both can be had.
85    StratifiedGrouped(StratifiedGrouped),
86    /// [`TimeSeries`]: growing prefixes.
87    TimeSeries(TimeSeries),
88}
89
90impl Partition {
91    /// The folds, in order — whichever scheme this is.
92    ///
93    /// Everything that cannot be honoured is an error **here**, before a single
94    /// index comes out: too few folds, more folds than samples, a class or a
95    /// group that cannot reach every fold. None of it is a warning, because a
96    /// silently degraded cut is a result you cannot tell from a good one.
97    pub fn folds(&self, samples: &Samples) -> Result<Vec<Fold>, PartitionError> {
98        match self {
99            Self::KFold(cut) => cut.folds(samples),
100            Self::Stratified(cut) => cut.folds(samples),
101            Self::Grouped(cut) => cut.folds(samples),
102            Self::StratifiedGrouped(cut) => cut.folds(samples),
103            Self::TimeSeries(cut) => cut.folds(samples),
104        }
105    }
106
107    /// How many folds it produces, without producing them.
108    pub fn k(&self) -> usize {
109        match self {
110            Self::KFold(cut) => cut.k,
111            Self::Stratified(cut) => cut.k,
112            Self::Grouped(cut) => cut.k,
113            Self::StratifiedGrouped(cut) => cut.k,
114            Self::TimeSeries(cut) => cut.k,
115        }
116    }
117}
118
119impl fmt::Display for Partition {
120    /// As text, which is the form that goes into a cache key and into the record
121    /// of a trial. Written by the scheme itself, because the name belongs with
122    /// the thing it names.
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::KFold(cut) => cut.fmt(f),
126            Self::Stratified(cut) => cut.fmt(f),
127            Self::Grouped(cut) => cut.fmt(f),
128            Self::StratifiedGrouped(cut) => cut.fmt(f),
129            Self::TimeSeries(cut) => cut.fmt(f),
130        }
131    }
132}
133
134/// `name:k`, and the seed only when there is one. Shared by the two schemes
135/// that take one, so their text cannot drift apart.
136pub(super) fn scheme(
137    f: &mut fmt::Formatter<'_>,
138    name: &str,
139    k: usize,
140    shuffle: Option<u64>,
141) -> fmt::Result {
142    match shuffle {
143        None => write!(f, "{name}:{k}"),
144        Some(seed) => write!(f, "{name}:{k}:shuffled:{seed}"),
145    }
146}
147
148/// Why the samples cannot be cut that way.
149///
150/// One type for the five schemes, and not one each: they are the ways a **cut**
151/// fails, and which scheme was asked for is already in the message.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum PartitionError {
154    /// Fewer than two folds.
155    TooFewFolds {
156        /// What was asked for.
157        k: usize,
158    },
159    /// More folds than there are samples to put in them.
160    MoreFoldsThanSamples {
161        /// What was asked for.
162        k: usize,
163        /// How many samples there are.
164        n: usize,
165    },
166    /// Stratifying was asked for and no class was said.
167    NeedsClasses,
168    /// Grouping was asked for and no group was said.
169    NeedsGroups,
170    /// A class with fewer members than folds cannot be in every fold.
171    ClassTooSmall {
172        /// Which one.
173        class: u32,
174        /// How many it has.
175        count: usize,
176        /// How many folds.
177        k: usize,
178    },
179    /// Fewer groups than folds: one fold would get nothing.
180    TooFewGroups {
181        /// How many distinct groups there are.
182        groups: usize,
183        /// How many folds.
184        k: usize,
185    },
186    /// The gap eats the whole of the first fold's training set.
187    GapTooLarge {
188        /// What was asked for.
189        gap: usize,
190        /// How many splits.
191        k: usize,
192    },
193}
194
195impl fmt::Display for PartitionError {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::TooFewFolds { k } => write!(
199                f,
200                "{k} folds is not a cut: with one, everything is held out and nothing trains"
201            ),
202            Self::MoreFoldsThanSamples { k, n } => write!(
203                f,
204                "{k} folds over {n} samples leaves folds with nothing in them"
205            ),
206            Self::NeedsClasses => f.write_str(
207                "stratifying needs the class of each sample: `Samples::of(n).by_class(…)`",
208            ),
209            Self::NeedsGroups => f.write_str(
210                "grouping needs the group of each sample: `Samples::of(n).in_groups(…)`",
211            ),
212            Self::ClassTooSmall { class, count, k } => write!(
213                f,
214                "class `{class}` has {count} samples and there are {k} folds: it cannot be in \
215                 all of them. Either fewer folds, or do not stratify by it"
216            ),
217            Self::TooFewGroups { groups, k } => write!(
218                f,
219                "{groups} groups over {k} folds: a group does not split, so one fold gets nothing"
220            ),
221            Self::GapTooLarge { gap, k } => write!(
222                f,
223                "a gap of {gap} leaves the first of {k} splits with nothing to train on"
224            ),
225        }
226    }
227}
228
229impl std::error::Error for PartitionError {}