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 {}