Skip to main content

contree/algorithms/
continuous_tree.rs

1use crate::algorithms::depth2::{ConTreeDepth2, Depth2Loss};
2use crate::algorithms::interval_pruner::{Bound, IntervalsPruner};
3use crate::algorithms::shared;
4use crate::algorithms::support_feasible_splits;
5use crate::caching::{Cache, Entry};
6use crate::common::{
7    Cost, FitOutcome, Loss, PointSelector, SearchConfig, SearchError, SearchStatus, Skipping,
8    Statistics,
9};
10use crate::data::view::DataView;
11use crate::data::Dataset;
12use crate::tree::Tree;
13use rand::rngs::StdRng;
14use rand::SeedableRng;
15use std::collections::VecDeque;
16use std::time::Instant;
17
18/// Exact ConTree search for optimal decision trees on continuous features.
19///
20/// A depth-first branch-and-bound search with caching. For each feature it
21/// explores candidate thresholds by bisection over intervals, and uses the
22/// errors already found to prune whole intervals of thresholds at once.
23pub struct ConTree {
24    config: SearchConfig,
25    statistics: Statistics,
26    cache: Cache,
27    specialized: ConTreeDepth2,
28    runtime: Instant,
29    rng: StdRng,
30    status: SearchStatus,
31    /// Every improvement of the root incumbent, as `(seconds, error)`.
32    trajectory: Vec<(f64, Cost)>,
33    loss: Loss,
34    /// The first negative or NaN cost the loss returned during the fit.
35    invalid_cost: Option<Cost>,
36    pub tree: Tree,
37}
38
39impl ConTree {
40    /// Builds a solver from a [`SearchConfig`].
41    ///
42    /// This is the preferred constructor; [`Self::new`] takes the same
43    /// settings as positional arguments.
44    pub fn with_config(config: SearchConfig) -> Self {
45        let mut solver = Self::new(
46            config.min_sup,
47            config.max_depth,
48            config.max_time,
49            config.max_error,
50            config.point_selector,
51            config.max_gap,
52            config.use_heuristic,
53            config.fast_d2,
54        );
55        solver.config = config;
56        solver
57    }
58
59    /// Builds a solver from positional settings. See [`Self::with_config`].
60    #[allow(clippy::too_many_arguments)]
61    pub fn new(
62        min_sup: usize,
63        max_depth: usize,
64        max_time: f64,
65        max_error: Cost,
66        split_selection_strategy: PointSelector,
67        max_gap: Cost,
68        use_heuristic: bool,
69        fast_d2: bool,
70    ) -> Self {
71        Self {
72            cache: Cache::default(),
73            config: SearchConfig::new(
74                min_sup,
75                max_depth,
76                max_time,
77                max_gap,
78                max_error,
79                use_heuristic,
80                fast_d2,
81                split_selection_strategy,
82            ),
83            statistics: Statistics::default(),
84            specialized: ConTreeDepth2,
85            runtime: Instant::now(),
86            rng: StdRng::from_os_rng(),
87            status: SearchStatus::Optimal,
88            trajectory: Vec::new(),
89            loss: Loss::default(),
90            invalid_cost: None,
91            tree: Tree::default(),
92        }
93    }
94
95    /// Minimises `loss` instead of misclassification.
96    ///
97    /// A loss that reads instance ids turns the depth-2 solver off, since it
98    /// works on class counts only.
99    pub fn with_loss(mut self, loss: Loss) -> Self {
100        self.loss = loss;
101        self
102    }
103
104    /// Chooses whether thresholds are skipped; see [`Skipping`].
105    pub fn with_skipping(mut self, skipping: Skipping) -> Self {
106        self.config.skipping = skipping;
107        self
108    }
109
110    /// Seeds the random generator used by `PointSelector::Random`, making
111    /// runs reproducible. Unseeded solvers draw their seed from the OS.
112    pub fn with_random_state(mut self, seed: u64) -> Self {
113        self.rng = StdRng::seed_from_u64(seed);
114        self
115    }
116
117    /// Searches for the optimal tree.
118    ///
119    /// Returns the tree, the search counters, and why the search stopped.
120    /// Only `SearchStatus::Optimal` means the tree is proven best for the
121    /// given depth and minimum support.
122    pub fn fit(&mut self, dataset: &Dataset) -> Result<FitOutcome, SearchError> {
123        crate::algorithms::validate(&self.config, dataset)?;
124        self.config.cost_step = SearchConfig::cost_step_for(&self.loss);
125        self.config.fast_d2 &= !self.loss.uses_indices();
126        self.invalid_cost = None;
127        self.statistics.skipping_violations = 0;
128
129        let root_view = DataView::root(dataset, self.config.use_heuristic);
130
131        self.cache = Cache::new(self.config.max_depth, root_view.total_instances);
132
133        let root_index = self.cache.init();
134
135        self.statistics.num_features = root_view.get_feature_number();
136        self.statistics.num_samples = root_view.total_instances;
137
138        let (error, label) = self.leaf(&root_view);
139        let mut entry = Entry {
140            error,
141            label,
142            ..Entry::default()
143        };
144
145        if let Some(entry) = self.cache.get_mut(root_index) {
146            self.config.max_error = self.config.max_error.min(error);
147            entry.error = error;
148            entry.label = label;
149        }
150
151        let root_config = self.config;
152        self.runtime = Instant::now();
153        self.trajectory.clear();
154        self.expand_node_with_view(
155            &root_view,
156            &root_config,
157            &mut entry,
158            0,
159            true,
160            root_config.max_error,
161        );
162
163        self.statistics.error = entry.error;
164        self.statistics.cache_size = self.cache.len();
165        self.statistics.duration = self.elapsed_time();
166        self.get_solution_tree();
167
168        if let Some(cost) = self.invalid_cost {
169            return Err(SearchError::InvalidLeafCost { cost });
170        }
171        self.status = if !self.time_remains() {
172            SearchStatus::TimeLimit
173        } else if entry.error <= self.config.max_gap && self.config.max_gap > 0.0 {
174            SearchStatus::ErrorBoundReached
175        } else if self.statistics.skipping_violations > 0 {
176            SearchStatus::NotProven
177        } else {
178            SearchStatus::Optimal
179        };
180        self.tree.validate()?;
181
182        Ok(FitOutcome {
183            tree: self.tree.clone(),
184            statistics: self.statistics,
185            status: self.status,
186        })
187    }
188
189    /// The anytime profile of the last fit: `(seconds, error)` at every
190    /// improvement of the root incumbent.
191    pub fn trajectory(&self) -> &[(f64, Cost)] {
192        &self.trajectory
193    }
194
195    /// Why the last `fit` stopped.
196    pub fn status(&self) -> SearchStatus {
197        self.status
198    }
199
200    /// The `(cost, label)` of the view as a single leaf.
201    fn leaf(&mut self, view: &DataView<'_>) -> (Cost, usize) {
202        shared::checked_leaf(
203            &self.loss,
204            view.get_labels_freqs(),
205            || view.instance_ids(),
206            &mut self.invalid_cost,
207        )
208    }
209
210    /// Looks a child subproblem up in the cache, seeding a new entry with the
211    /// cost of the view as a single leaf.
212    ///
213    /// `child_depth` is the child's remaining depth. Returns whether the entry
214    /// is new, its cache index, and a copy of it.
215    fn cache_child(&mut self, view: &DataView<'_>, child_depth: usize) -> (bool, usize, Entry) {
216        let (is_new, index) = self.cache.insert(&view.bitset, child_depth);
217        let depth = self.config.max_depth - child_depth;
218        let mut entry = Entry::default();
219
220        if let Some(slot) = self.cache.get_mut(index) {
221            if is_new {
222                let (error, label) = shared::checked_leaf(
223                    &self.loss,
224                    view.get_labels_freqs(),
225                    || view.instance_ids(),
226                    &mut self.invalid_cost,
227                );
228                slot.error = error;
229                slot.label = label;
230                slot.depth = depth;
231                slot.lower_bound = 0.0;
232                slot.is_valid = true;
233            }
234            entry = *slot;
235        }
236        (is_new, index, entry)
237    }
238
239    fn expand_node_with_view(
240        &mut self,
241        view: &DataView<'_>,
242        config: &SearchConfig,
243        current_best: &mut Entry,
244        parent_index: usize,
245        is_new: bool,
246        upper_bound: Cost,
247    ) {
248        #[cfg(feature = "profiling")]
249        coz::progress!();
250        if current_best.error == 0.0 || view.is_empty() {
251            // A cost of 0 can come from a split found by an earlier search of
252            // this subproblem: the entry is then not a leaf, and marking it
253            // one would drop that split from the solution tree.
254            if let Some(entry) = self.cache.get_mut(parent_index) {
255                entry.is_leaf = entry.feature == usize::MAX;
256                entry.mark_exact();
257                *current_best = *entry;
258            }
259            return;
260        }
261
262        // Only an entry proven exact can replace a new search. An entry that
263        // only showed "no better than the bound it ran under" says nothing
264        // about a later, looser bound.
265        if !is_new && current_best.is_optimal {
266            self.statistics.cache_hits += 1;
267            return;
268        }
269
270        if config.max_depth == 0 {
271            current_best.is_leaf = true;
272            current_best.mark_exact();
273
274            if let Some(entry) = self.cache.get_mut(parent_index) {
275                entry.is_leaf = true;
276                entry.mark_exact();
277                *current_best = *entry;
278            }
279
280            return;
281        }
282
283        if current_best.error <= config.max_gap || view.len() <= 1 {
284            // Within the allowed gap, or too few instances to split: the leaf
285            // is the answer.
286            current_best.mark_exact();
287            if let Some(entry) = self.cache.get_mut(parent_index) {
288                entry.mark_exact();
289            }
290            return;
291        }
292
293        // Only at depth exactly 2: `ConTreeDepth2` always builds two levels of
294        // tests, which would exceed a depth-1 request.
295        if config.fast_d2 && config.max_depth == 2 {
296            let skipping = config.skipping.applies(&self.loss);
297            let tree = self.specialized.fit(
298                view,
299                config,
300                current_best,
301                upper_bound,
302                &mut self.statistics,
303                &mut Depth2Loss::new(&self.loss, skipping, &mut self.invalid_cost),
304            );
305            current_best.finalize_lower_bound(upper_bound);
306            current_best.is_optimal = current_best.is_valid;
307            let tree_index = self.cache.insert_tree(tree);
308            if let Some(entry) = self.cache.get_mut(parent_index) {
309                *entry = *current_best;
310                entry.tree_idx = Some(tree_index);
311            }
312            self.statistics.specialized_solver_call += 1;
313
314            return;
315        }
316
317        let num_features = view.get_feature_number();
318        let heuristics_data = view.features_best_score();
319        debug_assert!(
320            num_features == heuristics_data.len(),
321            "Missmatch with heuristics and features number"
322        );
323        for &(_, feat) in heuristics_data.iter().take(num_features) {
324            self.expand_on_feature(
325                view,
326                feat,
327                parent_index,
328                config,
329                current_best,
330                upper_bound.min(current_best.error),
331            );
332
333            if current_best.error == 0.0 {
334                current_best.mark_exact();
335                if let Some(entry) = self.cache.get_mut(parent_index) {
336                    entry.mark_exact();
337                }
338
339                return;
340            }
341            if !self.time_remains() {
342                return;
343            }
344        }
345
346        // The entry becomes reusable only if the search found a tree within
347        // the upper bound it was given. Otherwise it only proves that the
348        // bound cannot be met here.
349        current_best.finalize_lower_bound(upper_bound);
350        current_best.is_optimal = current_best.is_valid;
351        if let Some(entry) = self.cache.get_mut(parent_index) {
352            entry.lower_bound = current_best.lower_bound;
353            entry.is_valid = current_best.is_valid;
354            entry.is_optimal = current_best.is_valid;
355        }
356    }
357
358    fn expand_on_feature(
359        &mut self,
360        view: &DataView<'_>,
361        feature_index: usize,
362        cache_index: usize,
363        config: &SearchConfig,
364        current_best: &mut Entry,
365        upper_bound: Cost,
366    ) {
367        #[cfg(feature = "profiling")]
368        coz::scope!("expand_on_feature");
369        let feature_column = view.get_sorted_feature(feature_index);
370        let feature_column_ids = view.get_feature_indices(feature_index);
371
372        if config.point_selector == PointSelector::First {
373            self.expand_on_feature_gini_priority(
374                view,
375                feature_index,
376                cache_index,
377                config,
378                current_best,
379                upper_bound,
380            );
381            return;
382        }
383
384        let possible_index_split = view.get_possible_split_indices(feature_index);
385        if possible_index_split.is_empty() {
386            return;
387        }
388
389        // Restrict the search to splits that meet the minimum support. The
390        // per-candidate check below skips a whole interval, so it cannot be
391        // relied on to find the feasible splits inside it.
392        let feasible =
393            support_feasible_splits(possible_index_split, view.len(), self.config.min_sup);
394        if feasible.is_empty() {
395            return;
396        }
397
398        let mut pruner = IntervalsPruner::new(
399            possible_index_split,
400            config.max_gap,
401            config.min_sup,
402            config.skipping.applies(&self.loss),
403        );
404        let mut queue = VecDeque::new();
405        let init_bound = Bound::new(feasible.start, feasible.end - 1, None, None);
406        queue.push_back(init_bound);
407
408        while let Some(mut current_bound) = queue.pop_front() {
409            if !self.time_remains() {
410                return;
411            }
412
413            // Prune against the tighter of the incumbent and the parent's upper
414            // bound: a split that cannot beat the parent's bound is of no use
415            // to the parent.
416            if pruner.subinterval_pruning(&current_bound, current_best.error.min(upper_bound)) {
417                continue;
418            }
419
420            pruner.interval_shrinking(&mut current_bound, current_best.error.min(upper_bound));
421            if !current_bound.is_valid() {
422                continue;
423            }
424
425            let selected_point = self.select_point(config, &current_bound);
426            let split_point = possible_index_split[selected_point];
427            let int_half_distance = split_point
428                .saturating_sub(possible_index_split[current_bound.left_bound])
429                .max(possible_index_split[current_bound.right_bound].saturating_sub(split_point))
430                as Cost;
431
432            let threshold_value = if selected_point > 0 {
433                let previous = feature_column_ids[possible_index_split[selected_point - 1]];
434                let point = feature_column_ids[split_point];
435                shared::threshold_between(
436                    feature_column[previous].value(),
437                    feature_column[point].value(),
438                )
439            } else {
440                let point = feature_column_ids[split_point];
441                shared::threshold_between(
442                    feature_column[feature_column_ids[0]].value(),
443                    feature_column[point].value(),
444                )
445            };
446
447            let (left_view, right_view) = view.split(feature_index, split_point);
448
449            if left_view.len() < self.config.min_sup || right_view.len() < self.config.min_sup {
450                continue;
451            }
452
453            // Search the larger child first: its error tightens the bound
454            // passed to the smaller one.
455            let process_left_first = left_view.len() >= right_view.len();
456
457            let left_config = config.derive_left();
458            let mut left_entry = Entry::default();
459            let mut right_entry = Entry::default();
460
461            // A cache index of 0 means "no child", kept by a side the search
462            // does not descend into.
463            let (mut left_index, mut right_index) = (0, 0);
464            let (mut left_is_new, mut right_is_new);
465
466            let larger_upper_bound = current_best.error.min(upper_bound + config.cost_step);
467            self.statistics.general_solver_call += 1;
468
469            if process_left_first {
470                (left_is_new, left_index, left_entry) =
471                    self.cache_child(&left_view, left_config.max_depth);
472
473                self.expand_node_with_view(
474                    &left_view,
475                    &left_config,
476                    &mut left_entry,
477                    left_index,
478                    left_is_new,
479                    larger_upper_bound,
480                );
481                left_entry.finalize_lower_bound(larger_upper_bound);
482            } else {
483                (right_is_new, right_index, right_entry) =
484                    self.cache_child(&right_view, left_config.max_depth);
485
486                self.expand_node_with_view(
487                    &right_view,
488                    &left_config,
489                    &mut right_entry,
490                    right_index,
491                    right_is_new,
492                    larger_upper_bound,
493                );
494                right_entry.finalize_lower_bound(larger_upper_bound);
495            }
496
497            // The bound left for the smaller child comes from what the larger
498            // child was proved to cost, i.e. its lower bound.
499            let larger_error = if process_left_first {
500                left_entry.lower_bound
501            } else {
502                right_entry.lower_bound
503            };
504            // Widen that bound by the interval half-distance so that one
505            // search can prune the whole interval around this split. Only the
506            // interval pruner gains from it, so without threshold skipping the
507            // bound is not widened.
508            let budget = current_best.error.min(upper_bound + config.cost_step) - larger_error;
509            let widening = if config.skipping.applies(&self.loss) {
510                int_half_distance
511            } else {
512                0.0
513            };
514            let smaller_ub = budget + widening;
515            let smaller_upper_bound = smaller_ub.max(0.0);
516            // Stays `None` when the smaller child is not searched because the
517            // larger one alone already exceeds the upper bound.
518            let mut right_error: Option<Cost> = None;
519
520            // Search the smaller child unless the budget is negative. A budget
521            // of exactly zero is still explored, since a zero-error subtree
522            // may exist.
523            if smaller_ub > 0.0 || budget == 0.0 {
524                self.statistics.general_solver_call += 1;
525                let right_config = config.derive_right(left_config.max_gap);
526
527                if process_left_first {
528                    (right_is_new, right_index, right_entry) =
529                        self.cache_child(&right_view, right_config.max_depth);
530
531                    self.expand_node_with_view(
532                        &right_view,
533                        &right_config,
534                        &mut right_entry,
535                        right_index,
536                        right_is_new,
537                        smaller_upper_bound,
538                    );
539                    right_entry.finalize_lower_bound(smaller_upper_bound);
540                } else {
541                    (left_is_new, left_index, left_entry) =
542                        self.cache_child(&left_view, right_config.max_depth);
543
544                    self.expand_node_with_view(
545                        &left_view,
546                        &right_config,
547                        &mut left_entry,
548                        left_index,
549                        left_is_new,
550                        smaller_upper_bound,
551                    );
552                    left_entry.finalize_lower_bound(smaller_upper_bound);
553                }
554
555                // Use lower bounds rather than errors: when a bound cut a
556                // child search short, its `error` is only an upper bound.
557                right_error = Some(right_entry.lower_bound);
558
559                let feature_best = left_entry.lower_bound + right_entry.lower_bound;
560                if left_entry.is_valid && right_entry.is_valid && feature_best < current_best.error
561                {
562                    current_best.error = feature_best;
563                    if config.is_root {
564                        self.trajectory.push((self.elapsed_time(), feature_best));
565                    }
566                    current_best.feature = feature_index;
567                    current_best.split = threshold_value;
568                    current_best.left = left_index;
569                    current_best.right = right_index;
570
571                    let is_optimal = feature_best == 0.0;
572                    current_best.is_optimal = is_optimal;
573
574                    if let Some(entry) = self.cache.get_mut(cache_index) {
575                        *entry = *current_best;
576                    }
577
578                    if feature_best == 0.0 {
579                        return;
580                    }
581                }
582            }
583
584            // `None` for a child the bound invalidated: its lower bound may be
585            // zero without the subtree being error-free, and the pruner would
586            // read a zero as "nothing beyond this split can do better".
587            let left_score = left_entry.is_valid.then_some(left_entry.lower_bound);
588            pruner.add_result(selected_point, left_score, right_error);
589            if current_bound.left_bound == current_bound.right_bound {
590                continue;
591            }
592
593            let score_difference = (left_entry.lower_bound + right_error.unwrap_or(0.0)
594                - current_best.error.min(upper_bound))
595            .max(0.0);
596
597            let (left_bound, right_bound) = pruner.neighbourhood_pruning(
598                score_difference,
599                current_bound.left_bound,
600                current_bound.right_bound,
601                selected_point,
602            );
603
604            if left_bound <= current_bound.right_bound {
605                queue.push_back(Bound {
606                    left_bound,
607                    right_bound: current_bound.right_bound,
608                    last_split_left_index: Some(selected_point),
609                    last_split_right_index: current_bound.last_split_right_index,
610                });
611            }
612
613            if current_bound.left_bound <= right_bound {
614                queue.push_back(Bound {
615                    left_bound: current_bound.left_bound,
616                    right_bound,
617                    last_split_left_index: current_bound.last_split_left_index,
618                    last_split_right_index: Some(selected_point),
619                });
620            }
621        }
622
623        if let Some(entry) = self.cache.get_mut(cache_index) {
624            entry.is_optimal = true;
625        }
626    }
627
628    fn expand_on_feature_gini_priority(
629        &mut self,
630        view: &DataView<'_>,
631        feature_index: usize,
632        cache_index: usize,
633        config: &SearchConfig,
634        current_best: &mut Entry,
635        upper_bound: Cost,
636    ) {
637        let feature_column = view.get_sorted_feature(feature_index);
638        let feature_column_ids = view.get_feature_indices(feature_index);
639
640        let possible_splits = view.get_possible_split_indices(feature_index);
641
642        if possible_splits.is_empty() {
643            return;
644        }
645
646        let sorted_by_heuristic_indices = view.ordered_possible_splits(feature_index);
647
648        let mut pruner = IntervalsPruner::new(
649            possible_splits,
650            config.max_gap,
651            config.min_sup,
652            config.skipping.applies(&self.loss),
653        );
654
655        let mut queue = VecDeque::new();
656        let init_bound = Bound::new(0, possible_splits.len() - 1, None, None);
657        queue.push_back(init_bound);
658
659        let mut pruned = vec![false; possible_splits.len()];
660
661        for &split_idx in sorted_by_heuristic_indices {
662            if !self.time_remains() {
663                return;
664            }
665
666            if pruned[split_idx] {
667                continue;
668            }
669
670            // The interval around `split_idx` that no pruned threshold
671            // separates from it.
672            let mut current_left = split_idx;
673            while current_left > 0 && pruned[current_left - 1] {
674                current_left -= 1;
675            }
676            current_left = current_left.saturating_sub(1);
677
678            let mut current_right = split_idx;
679            while current_right < possible_splits.len() - 1 && pruned[current_right + 1] {
680                current_right += 1;
681            }
682            if current_right < possible_splits.len() - 1 {
683                current_right += 1;
684            }
685
686            let split_point = possible_splits[split_idx];
687
688            let threshold_value = if split_idx > 0 {
689                let previous = feature_column_ids[possible_splits[split_idx - 1]];
690                let point = feature_column_ids[split_point];
691                shared::threshold_between(
692                    feature_column[previous].value(),
693                    feature_column[point].value(),
694                )
695            } else {
696                let point = feature_column_ids[split_point];
697                shared::threshold_between(
698                    feature_column[feature_column_ids[0]].value(),
699                    feature_column[point].value(),
700                )
701            };
702
703            let (left_view, right_view) = view.split(feature_index, split_point);
704
705            if left_view.len() < self.config.min_sup || right_view.len() < self.config.min_sup {
706                pruned[split_idx] = true;
707                continue;
708            }
709
710            // Search the larger child first: its error tightens the bound
711            // passed to the smaller one.
712            let process_left_first = left_view.len() >= right_view.len();
713
714            let left_config = config.derive_left();
715            let mut left_entry = Entry::default();
716            let mut right_entry = Entry::default();
717
718            // A cache index of 0 means "no child", kept by a side the search
719            // does not descend into.
720            let (mut left_index, mut right_index) = (0, 0);
721            let (mut left_is_new, mut right_is_new);
722
723            let int_half_distance = split_point
724                .saturating_sub(possible_splits[0])
725                .max(possible_splits[possible_splits.len() - 1].saturating_sub(split_point))
726                as Cost;
727
728            let larger_upper_bound = current_best.error.min(upper_bound + config.cost_step);
729            self.statistics.general_solver_call += 1;
730
731            if process_left_first {
732                (left_is_new, left_index, left_entry) =
733                    self.cache_child(&left_view, left_config.max_depth);
734
735                self.expand_node_with_view(
736                    &left_view,
737                    &left_config,
738                    &mut left_entry,
739                    left_index,
740                    left_is_new,
741                    larger_upper_bound,
742                );
743                left_entry.finalize_lower_bound(larger_upper_bound);
744            } else {
745                (right_is_new, right_index, right_entry) =
746                    self.cache_child(&right_view, left_config.max_depth);
747
748                self.expand_node_with_view(
749                    &right_view,
750                    &left_config,
751                    &mut right_entry,
752                    right_index,
753                    right_is_new,
754                    larger_upper_bound,
755                );
756                right_entry.finalize_lower_bound(larger_upper_bound);
757            }
758
759            // The bound left for the smaller child comes from what the larger
760            // child was proved to cost, i.e. its lower bound.
761            let larger_error = if process_left_first {
762                left_entry.lower_bound
763            } else {
764                right_entry.lower_bound
765            };
766            // Widen that bound by the interval half-distance so that one
767            // search can prune the whole interval around this split. Only the
768            // interval pruner gains from it, so without threshold skipping the
769            // bound is not widened.
770            let budget = current_best.error.min(upper_bound + config.cost_step) - larger_error;
771            let widening = if config.skipping.applies(&self.loss) {
772                int_half_distance
773            } else {
774                0.0
775            };
776            let smaller_ub = budget + widening;
777            let smaller_upper_bound = smaller_ub.max(0.0);
778            let mut right_error: Option<Cost> = None;
779
780            // Search the smaller child unless the budget is negative. A budget
781            // of exactly zero is still explored, since a zero-error subtree
782            // may exist.
783            if smaller_ub > 0.0 || budget == 0.0 {
784                self.statistics.general_solver_call += 1;
785                let right_config = config.derive_right(left_config.max_gap);
786
787                if process_left_first {
788                    (right_is_new, right_index, right_entry) =
789                        self.cache_child(&right_view, right_config.max_depth);
790
791                    self.expand_node_with_view(
792                        &right_view,
793                        &right_config,
794                        &mut right_entry,
795                        right_index,
796                        right_is_new,
797                        smaller_upper_bound,
798                    );
799                    right_entry.finalize_lower_bound(smaller_upper_bound);
800                } else {
801                    (left_is_new, left_index, left_entry) =
802                        self.cache_child(&left_view, right_config.max_depth);
803
804                    self.expand_node_with_view(
805                        &left_view,
806                        &right_config,
807                        &mut left_entry,
808                        left_index,
809                        left_is_new,
810                        smaller_upper_bound,
811                    );
812                    left_entry.finalize_lower_bound(smaller_upper_bound);
813                }
814
815                // Use lower bounds rather than errors: when a bound cut a
816                // child search short, its `error` is only an upper bound.
817                right_error = Some(right_entry.lower_bound);
818                let feature_best = left_entry.lower_bound + right_entry.lower_bound;
819                if left_entry.is_valid && right_entry.is_valid && feature_best < current_best.error
820                {
821                    current_best.error = feature_best;
822                    if config.is_root {
823                        self.trajectory.push((self.elapsed_time(), feature_best));
824                    }
825                    current_best.feature = feature_index;
826                    current_best.split = threshold_value;
827                    current_best.left = left_index;
828                    current_best.right = right_index;
829
830                    let is_optimal = feature_best == 0.0;
831                    current_best.is_optimal = is_optimal;
832
833                    if let Some(entry) = self.cache.get_mut(cache_index) {
834                        *entry = *current_best;
835                    }
836                }
837            }
838
839            // `None` for a child the bound invalidated: its lower bound may be
840            // zero without the subtree being error-free, and the pruner would
841            // read a zero as "nothing beyond this split can do better".
842            let left_score = left_entry.is_valid.then_some(left_entry.lower_bound);
843            pruner.add_result(split_idx, left_score, right_error);
844
845            let score_difference = (left_entry.lower_bound + right_error.unwrap_or(0.0)
846                - current_best.error.min(upper_bound))
847            .max(0.0);
848            let (new_left_bound, new_right_bound) = pruner.neighbourhood_pruning(
849                score_difference,
850                0,
851                possible_splits.len() - 1,
852                split_idx,
853            );
854
855            // `neighbourhood_pruning` leaves two intervals to search,
856            // `[current_left, new_right_bound]` and `[new_left_bound, current_right]`.
857            // Only the thresholds strictly between them can be skipped.
858            let skip_from = new_right_bound.saturating_add(1).max(current_left);
859            if skip_from < split_idx {
860                pruned[skip_from..split_idx].fill(true);
861            }
862            let skip_to = new_left_bound.min(current_right + 1);
863            if split_idx + 1 < skip_to {
864                pruned[split_idx + 1..skip_to].fill(true);
865            }
866
867            if current_best.error == 0.0 {
868                break;
869            }
870        }
871
872        if let Some(entry) = self.cache.get_mut(cache_index) {
873            entry.is_optimal = true;
874        }
875    }
876
877    pub fn statistics(&self) -> Statistics {
878        self.statistics
879    }
880
881    fn time_remains(&self) -> bool {
882        shared::time_remains(&self.runtime, self.config.max_time)
883    }
884
885    fn elapsed_time(&self) -> f64 {
886        shared::elapsed_time(&self.runtime)
887    }
888
889    pub fn select_point(&mut self, config: &SearchConfig, bound: &Bound) -> usize {
890        shared::select_point(config.point_selector, &mut self.rng, bound)
891    }
892
893    /// Rebuilds `self.tree` from the cache.
894    pub fn get_solution_tree(&mut self) {
895        self.tree = shared::build_solution_tree(&self.cache);
896    }
897}
898
899#[cfg(test)]
900mod contree_test {
901    use crate::algorithms::continuous_tree::ConTree;
902    use crate::common::{Cost, PointSelector};
903    use crate::reader::data_reader::DataReader;
904    use crate::reader::DataReaderError;
905    use crate::tests::fixture;
906
907    #[test]
908    fn deeper_search_never_scores_worse() -> Result<(), DataReaderError> {
909        let reader = DataReader::default();
910        let mut dataset = reader.read_file(&fixture("avila_1k.txt"))?;
911        dataset.sort_features();
912
913        let mut previous = Cost::INFINITY;
914        for depth in 1..=3 {
915            let mut contree: ConTree = ConTree::new(
916                1,
917                depth,
918                100.0,
919                Cost::INFINITY,
920                PointSelector::Mid,
921                0.0,
922                false,
923                true,
924            );
925            contree.fit(&dataset).expect("fit");
926
927            let error = contree.statistics().error;
928            assert!(
929                error <= previous,
930                "depth {depth} scored {error}, worse than depth {} at {previous}",
931                depth - 1
932            );
933            assert!(
934                error < dataset.count() as Cost,
935                "the search reported a nonsense error"
936            );
937            previous = error;
938        }
939
940        Ok(())
941    }
942
943    #[test]
944    fn the_depth_two_specialization_agrees_with_the_general_search() -> Result<(), DataReaderError>
945    {
946        let reader = DataReader::default();
947        let mut dataset = reader.read_file(&fixture("hepatitis.txt"))?;
948        dataset.sort_features();
949
950        // Both are exact at depth 2, so they must agree on the error even if
951        // they break ties towards different trees.
952        let mut general: ConTree = ConTree::new(
953            1,
954            2,
955            100.0,
956            Cost::INFINITY,
957            PointSelector::Mid,
958            0.0,
959            false,
960            false,
961        );
962        general.fit(&dataset).expect("fit");
963
964        let mut specialized: ConTree = ConTree::new(
965            1,
966            2,
967            100.0,
968            Cost::INFINITY,
969            PointSelector::Mid,
970            0.0,
971            false,
972            true,
973        );
974        specialized.fit(&dataset).expect("fit");
975
976        assert_eq!(general.statistics().error, specialized.statistics().error);
977
978        Ok(())
979    }
980}