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}