Skip to main content

silvic_core/data/
bin_mapper.rs

1//! Per-feature bin mappers: a port of LightGBM 3.3.5 `BinMapper::FindBin` (`src/io/bin.cpp`) with
2//! `FindBinWithZeroAsOneBin` and `GreedyFindBin`, under LightGBM's defaults `use_missing=true`,
3//! `zero_as_missing=false` and no forced bins, so the upper bounds, and hence the thresholds in a model, are the ones
4//! LightGBM builds from the same sample.
5//!
6//! As in LightGBM, the sample lists only the non-zero values: values with `|v| <= kZeroThreshold` (and rows absent
7//! from the sample) count as zeros, and zero always sits alone in a bin bounded by `±kZeroThreshold`.
8//!
9//! Where silvic departs from `FindBin`:
10//! - A numerical feature with one distinct sampled value and no NaN is trivial. LightGBM gives it a second, empty bin
11//!   (zero's) and only drops it through `feature_pre_filter`, which silvic does not otherwise apply.
12//! - A categorical feature keeps at most `max_bin` bins: LightGBM keeps adding categories past `max_bin` until they
13//!   cover 99% of the sample, which would overflow a byte.
14
15use std::fmt::Write as _;
16
17use crate::config::Params;
18
19/// LightGBM `kZeroThreshold` (`meta.h`): the float literal `1e-35f` widened to double.
20pub(crate) const ZERO_THRESHOLD: f64 = 1e-35_f32 as f64;
21
22/// Share of the non-missing sample the kept categories cover (`FindBin`'s `0.99f`, a float).
23const CATEGORY_COVERAGE: f32 = 0.99;
24
25#[derive(Debug, Clone, PartialEq)]
26pub enum BinMapper {
27    Numerical {
28        /// Ascending; the last is `+inf`.
29        upper_bounds: Vec<f64>,
30        /// Whether bin `upper_bounds.len()` holds NaN.
31        nan_bin: bool,
32        /// Smallest and largest sampled values, for `feature_infos`.
33        min: f64,
34        max: f64,
35    },
36    Categorical {
37        /// `categories[b - 1]` is the value of bin `b`; bin 0 is "other".
38        categories: Vec<i32>,
39    },
40    Trivial,
41}
42
43/// A sample summarised as LightGBM's `FindBin` sees it.
44struct Distinct {
45    /// Ascending distinct values, zero included when the sample has zeros or straddles zero.
46    values: Vec<f64>,
47    counts: Vec<i64>,
48    nan_count: i64,
49    total: i64,
50}
51
52impl Distinct {
53    /// `FindBin`'s distinct-value pass: values within one ulp merge into the larger, and zero (counted from the rows
54    /// the sample leaves out) is inserted where it sorts, with count 0 when the sample straddles zero without one.
55    fn new(sample: &[f64], total_rows: usize) -> Distinct {
56        let nan_count = sample.iter().filter(|v| v.is_nan()).count();
57        let mut sorted: Vec<f64> = sample.iter().copied().filter(|v| v.abs() > ZERO_THRESHOLD).collect();
58        sorted.sort_unstable_by(f64::total_cmp);
59        let total = total_rows.max(sample.len()) as i64;
60        let zero_count = total - sorted.len() as i64 - nan_count as i64;
61
62        let mut values = Vec::new();
63        let mut counts = Vec::new();
64        if sorted.first().is_none_or(|&first| first > 0.0 && zero_count > 0) {
65            values.push(0.0);
66            counts.push(zero_count);
67        }
68        if let Some(&first) = sorted.first() {
69            values.push(first);
70            counts.push(1);
71        }
72        for pair in sorted.windows(2) {
73            let (prev, value) = (pair[0], pair[1]);
74            if equal_ordered(prev, value) {
75                *values.last_mut().expect("non-empty") = value;
76                *counts.last_mut().expect("non-empty") += 1;
77                continue;
78            }
79            if prev < 0.0 && value > 0.0 {
80                values.push(0.0);
81                counts.push(zero_count);
82            }
83            values.push(value);
84            counts.push(1);
85        }
86        if sorted.last().is_some_and(|&last| last < 0.0 && zero_count > 0) {
87            values.push(0.0);
88            counts.push(zero_count);
89        }
90        Distinct {
91            values,
92            counts,
93            nan_count: nan_count as i64,
94            total,
95        }
96    }
97
98    fn present_values(&self) -> usize {
99        self.counts.iter().filter(|&&c| c > 0).count()
100    }
101}
102
103/// `Common::CheckDoubleEqualOrdered`: `b` is at most one ulp above `a`.
104fn equal_ordered(a: f64, b: f64) -> bool {
105    b <= a.next_up()
106}
107
108/// Pushes `value` unless it is within one ulp of the last bound, as `GreedyFindBin` does.
109fn push_bound(bounds: &mut Vec<f64>, value: f64) -> bool {
110    if bounds.last().is_some_and(|&last| equal_ordered(last, value)) {
111        return false;
112    }
113    bounds.push(value);
114    true
115}
116
117/// `GreedyFindBin`: bin upper bounds for ascending `values` with `counts`, ending with `+inf`.
118fn greedy_find_bin(values: &[f64], counts: &[i64], max_bin: i64, total_cnt: i64, min_data_in_bin: i64) -> Vec<f64> {
119    let mut bounds = Vec::new();
120    if values.len() as i64 <= max_bin {
121        let mut cur_cnt_inbin = 0;
122        for i in 0..values.len().saturating_sub(1) {
123            cur_cnt_inbin += counts[i];
124            if cur_cnt_inbin >= min_data_in_bin
125                && push_bound(&mut bounds, ((values[i] + values[i + 1]) / 2.0).next_up())
126            {
127                cur_cnt_inbin = 0;
128            }
129        }
130    } else {
131        let (upper, lower) = greedy_bin_edges(values, counts, max_bin, total_cnt, min_data_in_bin);
132        for (up, low) in upper.iter().zip(&lower[1..]) {
133            push_bound(&mut bounds, ((up + low) / 2.0).next_up());
134        }
135    }
136    bounds.push(f64::INFINITY);
137    bounds
138}
139
140/// The many-values branch of `GreedyFindBin`: values with at least a mean bin's count get a bin of their own, the
141/// rest fill bins of about the mean size of what remains. Returns each bin's largest and smallest value
142/// (`upper_bounds`, `lower_bounds`), trimmed to the bins used.
143fn greedy_bin_edges(
144    values: &[f64],
145    counts: &[i64],
146    max_bin: i64,
147    total_cnt: i64,
148    min_data_in_bin: i64,
149) -> (Vec<f64>, Vec<f64>) {
150    let mut max_bin = max_bin;
151    if min_data_in_bin > 0 {
152        max_bin = max_bin.min(total_cnt / min_data_in_bin).max(1);
153    }
154    let mut mean_bin_size = total_cnt as f64 / max_bin as f64;
155    let mut rest_bin_cnt = max_bin;
156    let mut rest_sample_cnt = total_cnt;
157    let is_big: Vec<bool> = counts.iter().map(|&c| c as f64 >= mean_bin_size).collect();
158    for (&count, _) in counts.iter().zip(&is_big).filter(|(_, &big)| big) {
159        rest_bin_cnt -= 1;
160        rest_sample_cnt -= count;
161    }
162    mean_bin_size = rest_sample_cnt as f64 / rest_bin_cnt as f64;
163
164    // One spare slot: with `max_bin == 1` LightGBM writes `lower_bounds[1]` past its vector and reads it back.
165    let slots = max_bin as usize + 1;
166    let mut upper = vec![f64::INFINITY; slots];
167    let mut lower = vec![f64::INFINITY; slots];
168    let mut bin_cnt = 0usize;
169    lower[0] = values[0];
170    let mut cur_cnt_inbin = 0;
171    for i in 0..values.len() - 1 {
172        if !is_big[i] {
173            rest_sample_cnt -= counts[i];
174        }
175        cur_cnt_inbin += counts[i];
176        let cur = cur_cnt_inbin as f64;
177        if is_big[i] || cur >= mean_bin_size || (is_big[i + 1] && cur >= f64::max(1.0, mean_bin_size * 0.5)) {
178            upper[bin_cnt] = values[i];
179            bin_cnt += 1;
180            lower[bin_cnt] = values[i + 1];
181            if bin_cnt as i64 >= max_bin - 1 {
182                break;
183            }
184            cur_cnt_inbin = 0;
185            if !is_big[i] {
186                rest_bin_cnt -= 1;
187                mean_bin_size = rest_sample_cnt as f64 / rest_bin_cnt as f64;
188            }
189        }
190    }
191    bin_cnt += 1;
192    upper.truncate(bin_cnt - 1);
193    lower.truncate(bin_cnt);
194    (upper, lower)
195}
196
197/// `FindBinWithZeroAsOneBin`: negatives and positives binned apart, zero alone between `-kZeroThreshold` and
198/// `kZeroThreshold`.
199fn find_bin_with_zero_as_one_bin(
200    distinct: &Distinct,
201    max_bin: i64,
202    total_sample_cnt: i64,
203    min_data_in_bin: i64,
204) -> Vec<f64> {
205    let (values, counts) = (&distinct.values, &distinct.counts);
206    let (mut left_cnt_data, mut cnt_zero, mut right_cnt_data) = (0i64, 0i64, 0i64);
207    for (&value, &count) in values.iter().zip(counts) {
208        if value <= -ZERO_THRESHOLD {
209            left_cnt_data += count;
210        } else if value > ZERO_THRESHOLD {
211            right_cnt_data += count;
212        } else {
213            cnt_zero += count;
214        }
215    }
216
217    let left_cnt = values.iter().position(|&v| v > -ZERO_THRESHOLD).unwrap_or(values.len());
218    let mut bounds = Vec::new();
219    if left_cnt > 0 && max_bin > 1 {
220        let share = left_cnt_data as f64 / (total_sample_cnt - cnt_zero) as f64 * (max_bin - 1) as f64;
221        let left_max_bin = (share as i64).max(1);
222        bounds = greedy_find_bin(
223            &values[..left_cnt],
224            &counts[..left_cnt],
225            left_max_bin,
226            left_cnt_data,
227            min_data_in_bin,
228        );
229        if let Some(last) = bounds.last_mut() {
230            *last = -ZERO_THRESHOLD;
231        }
232    }
233
234    let right_start = (left_cnt..values.len()).find(|&i| values[i] > ZERO_THRESHOLD);
235    let right_max_bin = max_bin - 1 - bounds.len() as i64;
236    match right_start {
237        Some(start) if right_max_bin > 0 => {
238            let right = greedy_find_bin(
239                &values[start..],
240                &counts[start..],
241                right_max_bin,
242                right_cnt_data,
243                min_data_in_bin,
244            );
245            bounds.push(ZERO_THRESHOLD);
246            bounds.extend(right);
247        }
248        _ => bounds.push(f64::INFINITY),
249    }
250    bounds
251}
252
253impl BinMapper {
254    /// Builds a numerical mapper from sampled values (NaN included) out of `total_rows` sampled rows; sampled rows
255    /// missing from `sample` are zeros, as in LightGBM's sparse sample.
256    pub fn numerical(sample: &[f64], total_rows: usize, params: &Params) -> BinMapper {
257        let distinct = Distinct::new(sample, total_rows);
258        let min_data_in_bin = params.min_data_in_bin as i64;
259        let max_bin = params.max_bin as i64;
260        let nan_bin = distinct.nan_count > 0;
261        let upper_bounds = if nan_bin {
262            find_bin_with_zero_as_one_bin(
263                &distinct,
264                max_bin - 1,
265                distinct.total - distinct.nan_count,
266                min_data_in_bin,
267            )
268        } else {
269            find_bin_with_zero_as_one_bin(&distinct, max_bin, distinct.total, min_data_in_bin)
270        };
271        let num_bins = upper_bounds.len() + usize::from(nan_bin);
272        debug_assert!(num_bins <= params.max_bin, "{num_bins} bins over max_bin");
273        if num_bins <= 1 || (!nan_bin && distinct.present_values() == 1) {
274            return BinMapper::Trivial;
275        }
276        BinMapper::Numerical {
277            upper_bounds,
278            nan_bin,
279            min: distinct.values[0],
280            max: *distinct.values.last().expect("non-empty"),
281        }
282    }
283
284    /// Builds a categorical mapper from sampled values out of `total_rows` sampled rows (absent rows are category 0),
285    /// following `FindBin`'s categorical branch: negative (after truncation to `int`) and NaN values are "other"; the
286    /// rest, by count descending then value ascending, become bins while they cover less than 99% of the sample or
287    /// fewer than `max_bin` bins are used, stopping at the third category with fewer than `min_data_in_bin` samples.
288    pub fn categorical(sample: &[f64], total_rows: usize, params: &Params) -> BinMapper {
289        let distinct = Distinct::new(sample, total_rows);
290        let mut other_count = distinct.nan_count;
291        let mut by_value: Vec<(i32, i64)> = Vec::new();
292        for (&value, &count) in distinct.values.iter().zip(&distinct.counts) {
293            let category = value as i32;
294            if category < 0 {
295                other_count += count;
296            } else if let Some(last) = by_value.last_mut().filter(|last| last.0 == category) {
297                last.1 += count;
298            } else {
299                by_value.push((category, count));
300            }
301        }
302        let rest_count = distinct.total - other_count;
303        if rest_count <= 0 {
304            return BinMapper::Trivial;
305        }
306        // Stable, so equal counts stay in ascending value order (`SortForPair` with `std::stable_sort`).
307        by_value.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
308        let cut_cnt = (f64::from(rest_count as f32 * CATEGORY_COVERAGE) + 0.5) as i64;
309        let distinct_cnt = by_value.len() + usize::from(other_count > 0);
310        let max_bin = distinct_cnt.min(params.max_bin);
311        let min_data_in_bin = params.min_data_in_bin as i64;
312
313        let mut categories = Vec::new();
314        let mut used_cnt = 0;
315        for (index, &(category, count)) in by_value.iter().enumerate() {
316            let num_bin = categories.len() + 1;
317            if !(used_cnt < cut_cnt || num_bin < max_bin) || num_bin >= params.max_bin {
318                break;
319            }
320            if count < min_data_in_bin && index > 1 {
321                break;
322            }
323            categories.push(category);
324            used_cnt += count;
325        }
326        if categories.is_empty() {
327            return BinMapper::Trivial;
328        }
329        BinMapper::Categorical { categories }
330    }
331
332    /// Bins in the histogram, NaN or "other" bin included; 0 when trivial.
333    pub fn num_bins(&self) -> u32 {
334        match self {
335            BinMapper::Numerical {
336                upper_bounds, nan_bin, ..
337            } => upper_bounds.len() as u32 + u32::from(*nan_bin),
338            BinMapper::Categorical { categories } => categories.len() as u32 + 1,
339            BinMapper::Trivial => 0,
340        }
341    }
342
343    /// LightGBM `BinMapper::ValueToBin`.
344    pub fn value_to_bin(&self, value: f64) -> u8 {
345        match self {
346            BinMapper::Numerical {
347                upper_bounds, nan_bin, ..
348            } => {
349                if value.is_nan() {
350                    if *nan_bin {
351                        return upper_bounds.len() as u8;
352                    }
353                    return upper_bounds.partition_point(|&bound| bound < 0.0) as u8;
354                }
355                upper_bounds.partition_point(|&bound| bound < value) as u8
356            }
357            BinMapper::Categorical { categories } => categorical_bin(categories, value),
358            BinMapper::Trivial => 0,
359        }
360    }
361
362    /// The `feature_infos` entry LightGBM writes: `[min:max]` (`%.17g`), `-1:c1:c2:...` (LightGBM lists its "other"
363    /// bin as category -1) or `none`.
364    pub fn info_string(&self) -> String {
365        match self {
366            BinMapper::Numerical { min, max, .. } => format!("[{}:{}]", format_g17(*min), format_g17(*max)),
367            BinMapper::Categorical { categories } => {
368                let mut out = String::from("-1");
369                for category in categories {
370                    write!(out, ":{category}").expect("write to String");
371                }
372                out
373            }
374            BinMapper::Trivial => "none".to_string(),
375        }
376    }
377}
378
379/// Categorical `ValueToBin`: the value truncates to `int`; negative, NaN and unknown values are bin 0.
380pub(crate) fn categorical_bin(categories: &[i32], value: f64) -> u8 {
381    if value.is_nan() {
382        return 0;
383    }
384    let category = value as i32;
385    if category < 0 {
386        return 0;
387    }
388    categories
389        .iter()
390        .position(|&c| c == category)
391        .map_or(0, |index| index as u8 + 1)
392}
393
394/// C's `%.17g` (C++ streams at `digits10 + 2`): 17 significant digits, fixed notation for exponents in `-4..17`,
395/// trailing zeros dropped.
396pub(crate) fn format_g17(value: f64) -> String {
397    if value.is_nan() {
398        return "nan".to_string();
399    }
400    if value.is_infinite() {
401        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
402    }
403    if value == 0.0 {
404        return if value.is_sign_negative() { "-0" } else { "0" }.to_string();
405    }
406    let scientific = format!("{value:.16e}");
407    let (mantissa, exponent) = scientific.split_once('e').expect("scientific notation");
408    let exponent: i32 = exponent.parse().expect("exponent");
409    if (-4..17).contains(&exponent) {
410        let decimals = (16 - exponent) as usize;
411        let fixed = format!("{value:.decimals$}");
412        return trim_fraction(&fixed).to_string();
413    }
414    let sign = if exponent < 0 { '-' } else { '+' };
415    format!("{}e{sign}{:02}", trim_fraction(mantissa), exponent.abs())
416}
417
418fn trim_fraction(number: &str) -> &str {
419    if number.contains('.') {
420        number.trim_end_matches('0').trim_end_matches('.')
421    } else {
422        number
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn params(max_bin: usize, min_data_in_bin: usize) -> Params {
431        Params {
432            max_bin,
433            min_data_in_bin,
434            ..Params::default()
435        }
436    }
437
438    /// A deterministic pseudo-random stream for test values.
439    fn values(seed: u32, count: usize) -> Vec<f64> {
440        (0..count as u32)
441            .map(|i| {
442                let h = crate::quantize::mix32(crate::quantize::mix32(i) ^ seed);
443                (f64::from(h >> 8) / 16_777_216.0 - 0.5) * 8.0
444            })
445            .collect()
446    }
447
448    fn bounds(mapper: &BinMapper) -> &[f64] {
449        match mapper {
450            BinMapper::Numerical { upper_bounds, .. } => upper_bounds,
451            other => panic!("not numerical: {other:?}"),
452        }
453    }
454
455    #[test]
456    fn value_to_bin_agrees_with_thresholds() {
457        let mut sample = values(1, 5000);
458        sample.extend([0.0; 300]);
459        sample.extend([1.25; 400]);
460        for (case, mapper) in [
461            (
462                "max_bin 255",
463                BinMapper::numerical(&sample, sample.len(), &params(255, 3)),
464            ),
465            ("max_bin 7", BinMapper::numerical(&sample, sample.len(), &params(7, 3))),
466            ("with nan", {
467                let mut with_nan = sample.clone();
468                with_nan.push(f64::NAN);
469                BinMapper::numerical(&with_nan, with_nan.len(), &params(63, 3))
470            }),
471        ] {
472            let upper_bounds = bounds(&mapper).to_vec();
473            assert_eq!(
474                *upper_bounds.last().expect("bounds"),
475                f64::INFINITY,
476                "{case}: last bound"
477            );
478            assert!(upper_bounds.windows(2).all(|w| w[0] < w[1]), "{case}: ascending");
479            let mut probes = values(2, 2000);
480            for &bound in &upper_bounds {
481                probes.extend([bound, bound.next_up(), bound.next_down()]);
482            }
483            probes.extend([
484                0.0,
485                -0.0,
486                f64::INFINITY,
487                f64::NEG_INFINITY,
488                ZERO_THRESHOLD,
489                -ZERO_THRESHOLD,
490                1e-300,
491            ]);
492            for value in probes {
493                let bin = usize::from(mapper.value_to_bin(value));
494                assert!(bin < upper_bounds.len(), "{case}: {value} bin {bin}");
495                for (t, &bound) in upper_bounds.iter().enumerate() {
496                    assert_eq!(
497                        bin <= t,
498                        value <= bound,
499                        "{case}: value {value} bin {bin} threshold {t}"
500                    );
501                }
502            }
503        }
504    }
505
506    #[test]
507    fn nan_bin_present_or_absent() {
508        let mut sample = values(3, 1000);
509        let without = BinMapper::numerical(&sample, sample.len(), &params(255, 3));
510        let zero_bin = without.value_to_bin(0.0);
511        assert_eq!(without.value_to_bin(f64::NAN), zero_bin, "no nan bin: NaN bins as zero");
512        assert_eq!(without.num_bins() as usize, bounds(&without).len());
513
514        sample.push(f64::NAN);
515        let with = BinMapper::numerical(&sample, sample.len(), &params(255, 3));
516        let BinMapper::Numerical {
517            upper_bounds, nan_bin, ..
518        } = &with
519        else {
520            panic!("numerical")
521        };
522        assert!(*nan_bin);
523        assert_eq!(with.num_bins() as usize, upper_bounds.len() + 1);
524        assert_eq!(usize::from(with.value_to_bin(f64::NAN)), upper_bounds.len());
525        assert!(with.num_bins() <= 255);
526    }
527
528    #[test]
529    fn trivial_and_all_nan() {
530        let p = params(255, 3);
531        assert_eq!(BinMapper::numerical(&[2.5; 10], 10, &p), BinMapper::Trivial);
532        assert_eq!(BinMapper::numerical(&[], 10, &p), BinMapper::Trivial, "all zero");
533        assert_eq!(
534            BinMapper::numerical(&[1e-36; 4], 4, &p),
535            BinMapper::Trivial,
536            "below kZeroThreshold is zero"
537        );
538        let one_and_nan = BinMapper::numerical(&[2.5, f64::NAN], 2, &p);
539        assert_eq!(one_and_nan.num_bins(), 3, "zero bin, value bin, nan bin");
540        let zeros_and_value = BinMapper::numerical(&[2.5; 10], 20, &p);
541        assert_eq!(bounds(&zeros_and_value), &[ZERO_THRESHOLD, f64::INFINITY]);
542        assert_eq!(zeros_and_value.info_string(), "[0:2.5]");
543        assert_eq!(BinMapper::Trivial.info_string(), "none");
544    }
545
546    #[test]
547    fn few_distinct_values_split_at_midpoints() {
548        let sample: Vec<f64> = [-2.0, -1.0, 1.0, 2.0, 3.0].iter().flat_map(|&v| [v; 5]).collect();
549        let mapper = BinMapper::numerical(&sample, sample.len() + 5, &params(255, 3));
550        assert_eq!(
551            bounds(&mapper),
552            &[
553                (-1.5f64).next_up(),
554                -ZERO_THRESHOLD,
555                ZERO_THRESHOLD,
556                1.5f64.next_up(),
557                2.5f64.next_up(),
558                f64::INFINITY
559            ]
560        );
561        assert_eq!(mapper.info_string(), "[-2:3]");
562    }
563
564    #[test]
565    fn categorical_orders_by_count_then_value() {
566        let mut sample = Vec::new();
567        for (value, count) in [
568            (3.0, 10),
569            (7.0, 30),
570            (1.0, 10),
571            (2.7, 20),
572            (-4.0, 50),
573            (f64::NAN, 5),
574            (9.0, 1),
575        ] {
576            sample.extend(std::iter::repeat_n(value, count));
577        }
578        // 15 rows outside the sample are category 0.
579        let total = sample.len() + 15;
580        let mapper = BinMapper::categorical(&sample, total, &params(255, 3));
581        // 9 has one sample: past the second category, under min_data_in_bin, so it is dropped.
582        assert_eq!(
583            mapper,
584            BinMapper::Categorical {
585                categories: vec![7, 2, 0, 1, 3]
586            }
587        );
588        assert_eq!(mapper.num_bins(), 6);
589        assert_eq!(mapper.info_string(), "-1:7:2:0:1:3");
590        for (value, bin) in [
591            (7.0, 1),
592            (2.9, 2),
593            (0.0, 3),
594            (-0.5, 3),
595            (3.0, 5),
596            (-4.0, 0),
597            (f64::NAN, 0),
598            (9.0, 0),
599        ] {
600            assert_eq!(mapper.value_to_bin(value), bin, "category {value}");
601        }
602    }
603
604    #[test]
605    fn categorical_caps_at_max_bin() {
606        let sample: Vec<f64> = (1..=40).flat_map(|v| std::iter::repeat_n(f64::from(v), 10)).collect();
607        let mapper = BinMapper::categorical(&sample, sample.len(), &params(16, 3));
608        let BinMapper::Categorical { categories } = &mapper else {
609            panic!("categorical")
610        };
611        assert_eq!(categories, &(1..=15).collect::<Vec<_>>());
612        assert_eq!(mapper.num_bins(), 16);
613    }
614
615    #[test]
616    fn categorical_trivial_without_categories() {
617        let p = params(255, 3);
618        assert_eq!(BinMapper::categorical(&[-1.0, f64::NAN], 2, &p), BinMapper::Trivial);
619        assert_eq!(
620            BinMapper::categorical(&[4.0; 3], 3, &p),
621            BinMapper::Categorical { categories: vec![4] }
622        );
623    }
624
625    #[test]
626    fn g17_matches_printf() {
627        for (value, expected) in [
628            (0.1, "0.10000000000000001"),
629            (1.0, "1"),
630            (-5.0, "-5"),
631            (123_456.0, "123456"),
632            (1e16, "10000000000000000"),
633            (1e17, "1e+17"),
634            (0.0001, "0.0001"),
635            (0.000_012_5, "1.2500000000000001e-05"),
636            (ZERO_THRESHOLD, "1.0000000180025095e-35"),
637        ] {
638            assert_eq!(format_g17(value), expected, "{value:e}");
639        }
640        for printed in [
641            "-3.7722751561227339",
642            "3.0660367390488967",
643            "933.34701911841285",
644            "1.2345678901234568e+17",
645        ] {
646            let value: f64 = printed.parse().expect("number");
647            assert_eq!(format_g17(value), printed);
648        }
649    }
650}