Skip to main content

gam_problem/
outer_subsample.rs

1//! Outer-loop row subsampling primitive shared across the solver and the
2//! family-specific outer-score evaluators.
3//!
4//! [`OuterScoreSubsample`] and its per-row [`WeightedOuterRow`] are the
5//! Horvitz–Thompson row subsample consumed on outer-loop hot paths. They live
6//! in the solver layer (below `families`) so that both the solver's row-measure
7//! machinery and the family outer-score builders can depend on them downward,
8//! without `solver` reaching up into `families`. The stratified *builders* that
9//! construct these (`build_outer_score_subsample`, `auto_outer_score_subsample`)
10//! remain in `families::marginal_slope_shared`, since they depend on
11//! family-specific fit options; they import this type downward.
12
13use std::sync::Arc;
14
15/// Stratified row index subsample shared across outer-loop evaluations.
16///
17/// `mask` is sorted, deduplicated, and never empty in practice (enforced by
18/// `build_outer_score_subsample`).
19///
20/// Per-row inverse-inclusion weights `w_i = N_h / k_h` (where `h` is the row's
21/// stratum) are stored alongside the mask in `rows`. The Horvitz–Thompson
22/// estimator for any linear-in-row functional T = Σ_i f_i is
23///   T̂ = Σ_{i ∈ mask} w_i · f_i,
24/// which is unbiased even when per-stratum sampling fractions differ
25/// (the `ceil(k * N_h / n).max(1)` rule in the stratified builder makes
26/// rare strata oversample relative to the bulk, so a single global rescale
27/// `n_full / |mask|` is biased in those strata).
28///
29/// `weight_scale` is retained as a *diagnostic* (mean of `w_i` across the
30/// mask). It equals `n_full / |mask|` when all rows share a uniform inclusion
31/// probability (the caller-supplied-mask case represented by
32/// [`OuterScoreSubsample::from_uniform_inclusion_mask`]); it can drift from
33/// that value under the stratified builder's rare-stratum boost. It is not the
34/// per-row scaling factor — consumers must read `rows[i].weight` for HT
35/// correctness.
36///
37/// # Horvitz–Thompson contract
38///
39/// Per-row weight `rows[i].weight = 1 / π_i`, where `π_i` is the
40/// inclusion probability of row `i` under the stratified sampler. Any
41/// outer-only score/gradient routine that consumes this subsample must
42/// form `Σ_{i ∈ mask} w_i · f_i` so the resulting estimator is unbiased:
43///
44/// ```text
45///   E[ score_subsample ]  =  score_full.
46/// ```
47///
48/// The following families consume this subsample on their outer-loop hot
49/// paths: Gaussian-LS, Binomial-LS, the Wiggle variants, CTN, and
50/// Survival-LS. Each routes the `rows[i].weight` factor through its
51/// per-row accumulator (gradient, Hessian-action, trace probes).
52///
53/// # Convergence warning
54///
55/// Subsampled gradients are noisy by construction. The outer driver must
56/// **never** declare convergence on a subsampled gradient — near
57/// convergence it switches back to the full-data score so that the KKT
58/// stopping test sees the unbiased, low-variance signal. New consumers
59/// adding subsampled paths must preserve this invariant.
60#[derive(Debug, Clone)]
61pub struct OuterScoreSubsample {
62    pub mask: Arc<Vec<usize>>,
63    pub rows: Arc<Vec<WeightedOuterRow>>,
64    pub n_full: usize,
65    pub weight_scale: f64,
66    pub seed: u64,
67}
68
69impl OuterScoreSubsample {
70    /// Wrap a precomputed mask sampled with a uniform inclusion probability,
71    /// assigning each selected row the inverse-inclusion weight `n_full / m`.
72    /// The caller is responsible for sortedness and uniqueness;
73    /// `build_outer_score_subsample` remains the stratified per-row HT builder.
74    pub fn from_uniform_inclusion_mask(mask: Vec<usize>, n_full: usize, seed: u64) -> Self {
75        let m = mask.len();
76        let w = if m == 0 {
77            1.0
78        } else {
79            n_full as f64 / m as f64
80        };
81        Self::with_uniform_weight(mask, n_full, seed, w)
82    }
83
84    /// Wrap a precomputed mask with an explicit uniform per-row weight.
85    /// Useful for tests that need the unrescaled (`weight = 1.0`) sum over a
86    /// custom mask, and for callers that already know the desired
87    /// rescaling factor and don't want the constructor to derive it from
88    /// `n_full / |mask|`.
89    pub fn with_uniform_weight(mask: Vec<usize>, n_full: usize, seed: u64, weight: f64) -> Self {
90        let rows: Vec<WeightedOuterRow> = mask
91            .iter()
92            .map(|&index| WeightedOuterRow {
93                index,
94                weight,
95                stratum: 0,
96            })
97            .collect();
98        let weight_scale = if rows.is_empty() { 1.0 } else { weight };
99        Self {
100            mask: Arc::new(mask),
101            rows: Arc::new(rows),
102            n_full,
103            weight_scale,
104            seed,
105        }
106    }
107
108    /// Wrap a vector of `(index, weight, stratum)` triples. The mask is
109    /// derived as the sorted/dedup'd index list. Used by the stratified
110    /// builder to install per-row HT weights.
111    pub fn from_weighted_rows(mut rows: Vec<WeightedOuterRow>, n_full: usize, seed: u64) -> Self {
112        rows.sort_by_key(|r| r.index);
113        rows.dedup_by_key(|r| r.index);
114        let mask: Vec<usize> = rows.iter().map(|r| r.index).collect();
115        let weight_scale = if rows.is_empty() {
116            1.0
117        } else {
118            rows.iter().map(|r| r.weight).sum::<f64>() / rows.len() as f64
119        };
120        Self {
121            mask: Arc::new(mask),
122            rows: Arc::new(rows),
123            n_full,
124            weight_scale,
125            seed,
126        }
127    }
128
129    #[inline]
130    pub fn len(&self) -> usize {
131        self.mask.len()
132    }
133
134    #[inline]
135    pub fn is_empty(&self) -> bool {
136        self.mask.is_empty()
137    }
138
139}
140
141#[derive(Debug, Clone, Copy)]
142pub struct WeightedOuterRow {
143    pub index: usize,
144    pub weight: f64,
145    /// Stratum identifier the row was drawn from. Pure diagnostic — consumers
146    /// must use `weight` for any aggregation.
147    pub stratum: u32,
148}
149
150/// Deterministic row-block tiling constant for the parallel reduction paths.
151///
152/// All cross-row summations chunk the rows into `ARROW_ROW_CHUNK`-sized tiles
153/// and reduce the per-tile partials in tile-index order on the caller thread,
154/// so the floating-point reduction tree is fixed across Rayon worker counts and
155/// work-stealing decisions. Consumers that require deterministic associativity
156/// must keep their tiling a multiple of this constant.
157pub const ARROW_ROW_CHUNK: usize = 256;
158
159/// Number of `ARROW_ROW_CHUNK`-sized tiles covering `n_rows`.
160#[inline]
161pub fn arrow_row_chunk_count(n_rows: usize) -> usize {
162    if n_rows == 0 {
163        0
164    } else {
165        (n_rows - 1) / ARROW_ROW_CHUNK + 1
166    }
167}
168
169/// Row selection for an outer-loop evaluation: either the full data (`All`) or
170/// a Horvitz–Thompson [`WeightedOuterRow`] subsample.
171///
172/// `All` walks rows `0..n_total` with unit weight; `Subsample` walks the stored
173/// rows applying each row's inverse-inclusion scale `1/π_i`, so any partial sum
174/// `Σ_i w_i · f(row_i)` is an unbiased estimator of the corresponding full-data
175/// sum `Σ_{i=1..n_full} f(row_i)`. Inner-PIRLS and final-covariance passes
176/// always run with `All`; only outer score / gradient hot loops consume a
177/// non-`All` variant.
178///
179/// Lives in this lower layer (below `families`/`terms`) so the row-kernel
180/// consumers and the term hot-paths can name it without the `Subsample` field
181/// reaching up into `solver` (#1135). The family-specific constructor
182/// (`families::row_kernel::RowSet::from_options`, which reads
183/// `custom_family::BlockwiseFitOptions`) stays in `families` as an extension
184/// `impl` block.
185#[derive(Clone)]
186pub enum RowSet {
187    All,
188    Subsample {
189        rows: Arc<Vec<WeightedOuterRow>>,
190        n_full: usize,
191    },
192}
193
194impl RowSet {
195    /// Parallel fold-reduce over the row set. `init` produces a fresh
196    /// accumulator, `fold` is the per-row update, `reduce` combines two
197    /// accumulators.
198    ///
199    /// Returns the reduced result. Both branches process fixed-size row chunks
200    /// in parallel, then combine the chunk accumulators in chunk-index order on
201    /// the caller thread. The resulting floating-point reduction tree is fixed
202    /// across Rayon worker counts and work-stealing decisions.
203    #[inline]
204    pub fn par_reduce_fold<T, I, F, R>(&self, n_total: usize, init: I, fold: F, reduce: R) -> T
205    where
206        T: Send,
207        I: Fn() -> T + Send + Sync,
208        F: Fn(T, usize, f64) -> T + Send + Sync,
209        R: Fn(T, T) -> T + Send + Sync,
210    {
211        use rayon::iter::{IntoParallelIterator, ParallelIterator};
212        use rayon::slice::ParallelSlice;
213        match self {
214            Self::All => {
215                let chunk_accumulators: Vec<T> = (0..arrow_row_chunk_count(n_total))
216                    .into_par_iter()
217                    .map(|chunk_idx| {
218                        let start = chunk_idx * ARROW_ROW_CHUNK;
219                        let end = (start + ARROW_ROW_CHUNK).min(n_total);
220                        let mut acc = init();
221                        for i in start..end {
222                            acc = fold(acc, i, 1.0);
223                        }
224                        acc
225                    })
226                    .collect();
227                let mut total = init();
228                for acc in chunk_accumulators {
229                    total = reduce(total, acc);
230                }
231                total
232            }
233            Self::Subsample { rows, .. } => {
234                let chunk_accumulators: Vec<T> = rows
235                    .par_chunks(ARROW_ROW_CHUNK)
236                    .map(|chunk| {
237                        let mut acc = init();
238                        for r in chunk {
239                            acc = fold(acc, r.index, r.weight);
240                        }
241                        acc
242                    })
243                    .collect();
244                let mut total = init();
245                for acc in chunk_accumulators {
246                    total = reduce(total, acc);
247                }
248                total
249            }
250        }
251    }
252
253    /// Parallel try-fold over fixed-size row chunks, followed by deterministic
254    /// chunk-index-order reduction on the caller thread.
255    #[inline]
256    pub fn par_try_reduce_fold<T, E, I, F, R>(
257        &self,
258        n_total: usize,
259        init: I,
260        fold: F,
261        reduce: R,
262    ) -> Result<T, E>
263    where
264        T: Send,
265        E: Send,
266        I: Fn() -> T + Send + Sync,
267        F: Fn(T, usize, f64) -> Result<T, E> + Send + Sync,
268        R: Fn(T, T) -> Result<T, E> + Send + Sync,
269    {
270        use rayon::iter::{IntoParallelIterator, ParallelIterator};
271        use rayon::slice::ParallelSlice;
272        match self {
273            Self::All => {
274                let chunk_accumulators: Vec<Result<T, E>> = (0..arrow_row_chunk_count(n_total))
275                    .into_par_iter()
276                    .map(|chunk_idx| {
277                        let start = chunk_idx * ARROW_ROW_CHUNK;
278                        let end = (start + ARROW_ROW_CHUNK).min(n_total);
279                        let mut acc = init();
280                        for i in start..end {
281                            acc = fold(acc, i, 1.0)?;
282                        }
283                        Ok(acc)
284                    })
285                    .collect();
286                let mut total = init();
287                for acc in chunk_accumulators {
288                    total = reduce(total, acc?)?;
289                }
290                Ok(total)
291            }
292            Self::Subsample { rows, .. } => {
293                let chunk_accumulators: Vec<Result<T, E>> = rows
294                    .par_chunks(ARROW_ROW_CHUNK)
295                    .map(|chunk| {
296                        let mut acc = init();
297                        for r in chunk {
298                            acc = fold(acc, r.index, r.weight)?;
299                        }
300                        Ok(acc)
301                    })
302                    .collect();
303                let mut total = init();
304                for acc in chunk_accumulators {
305                    total = reduce(total, acc?)?;
306                }
307                Ok(total)
308            }
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    // ── arrow_row_chunk_count ─────────────────────────────────────────────────
318
319    #[test]
320    fn chunk_count_zero_rows_is_zero() {
321        assert_eq!(arrow_row_chunk_count(0), 0);
322    }
323
324    #[test]
325    fn chunk_count_one_row_is_one() {
326        assert_eq!(arrow_row_chunk_count(1), 1);
327    }
328
329    #[test]
330    fn chunk_count_exact_multiple() {
331        assert_eq!(arrow_row_chunk_count(ARROW_ROW_CHUNK), 1);
332        assert_eq!(arrow_row_chunk_count(ARROW_ROW_CHUNK * 3), 3);
333    }
334
335    #[test]
336    fn chunk_count_just_over_boundary() {
337        assert_eq!(arrow_row_chunk_count(ARROW_ROW_CHUNK + 1), 2);
338    }
339
340    // ── OuterScoreSubsample::from_uniform_inclusion_mask ─────────────────────
341
342    #[test]
343    fn uniform_mask_weight_scale_is_n_full_over_m() {
344        let mask = vec![0usize, 2, 4];
345        let s = OuterScoreSubsample::from_uniform_inclusion_mask(mask, 6, 0);
346        assert_eq!(s.len(), 3);
347        assert!((s.weight_scale - 2.0).abs() < 1e-14);
348        assert!(s.rows.iter().all(|r| (r.weight - 2.0).abs() < 1e-14));
349    }
350
351    #[test]
352    fn uniform_mask_empty_has_weight_scale_one() {
353        let s = OuterScoreSubsample::from_uniform_inclusion_mask(vec![], 10, 0);
354        assert_eq!(s.len(), 0);
355        assert!(s.is_empty());
356        assert_eq!(s.weight_scale, 1.0);
357    }
358
359    // ── OuterScoreSubsample::from_weighted_rows ───────────────────────────────
360
361    #[test]
362    fn weighted_rows_sorts_and_deduplicates() {
363        let rows = vec![
364            WeightedOuterRow {
365                index: 3,
366                weight: 2.0,
367                stratum: 0,
368            },
369            WeightedOuterRow {
370                index: 1,
371                weight: 1.0,
372                stratum: 0,
373            },
374            WeightedOuterRow {
375                index: 3,
376                weight: 2.0,
377                stratum: 0,
378            }, // duplicate
379        ];
380        let s = OuterScoreSubsample::from_weighted_rows(rows, 10, 42);
381        assert_eq!(s.len(), 2);
382        assert_eq!(s.mask[0], 1);
383        assert_eq!(s.mask[1], 3);
384    }
385
386    #[test]
387    fn weighted_rows_weight_scale_is_average_weight() {
388        let rows = vec![
389            WeightedOuterRow {
390                index: 0,
391                weight: 1.0,
392                stratum: 0,
393            },
394            WeightedOuterRow {
395                index: 1,
396                weight: 3.0,
397                stratum: 0,
398            },
399        ];
400        let s = OuterScoreSubsample::from_weighted_rows(rows, 10, 0);
401        assert!((s.weight_scale - 2.0).abs() < 1e-14);
402    }
403
404    // ── OuterScoreSubsample::has_variable_weights ─────────────────────────────
405
406    // ── RowSet::par_reduce_fold ───────────────────────────────────────────────
407
408    #[test]
409    fn row_set_all_sums_indices_zero_to_n() {
410        let rs = RowSet::All;
411        let sum: f64 =
412            rs.par_reduce_fold(5, || 0.0_f64, |acc, i, w| acc + i as f64 * w, |a, b| a + b);
413        // 1*0 + 1*1 + 1*2 + 1*3 + 1*4 = 10
414        assert!((sum - 10.0).abs() < 1e-14);
415    }
416
417    #[test]
418    fn row_set_subsample_applies_per_row_weight() {
419        let rows = Arc::new(vec![
420            WeightedOuterRow {
421                index: 2,
422                weight: 3.0,
423                stratum: 0,
424            },
425            WeightedOuterRow {
426                index: 5,
427                weight: 2.0,
428                stratum: 0,
429            },
430        ]);
431        let rs = RowSet::Subsample { rows, n_full: 10 };
432        let sum: f64 =
433            rs.par_reduce_fold(10, || 0.0_f64, |acc, i, w| acc + w * i as f64, |a, b| a + b);
434        // 3.0 * 2 + 2.0 * 5 = 6 + 10 = 16
435        assert!((sum - 16.0).abs() < 1e-14);
436    }
437}