Algorithm-Classifier-IsolationForest
view release on metacpan or search on metacpan
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
# parent never loads this module at compile time (its from_json requires
# it on demand), so there is no cycle.
use Algorithm::Classifier::IsolationForest ();
our $VERSION = '0.6.0';
# Node layout. Unlike the batch forest's nodes, online nodes are mutable
# and carry a running point count plus the bounding box (per-feature
# lo/hi) of every point that has passed through them -- that box is what
# split simulation samples from, since points themselves are never stored
# in the tree. Both node types share the first four slots so the
# learn/unlearn bookkeeping never has to branch on type:
#
# leaf: [0, count, \@lo, \@hi]
# internal: [1, count, \@lo, \@hi, attr, split, left, right]
#
# The type tag mirrors the parent's convention (0 is falsy, so
# while ($node->[0]) walks to a leaf). A leaf built from an empty
# synthetic partition has count 0 and an undef box (slots 2/3); the box
# is initialised from the first real point that reaches it.
use constant _N_TYPE => 0;
use constant _N_COUNT => 1;
use constant _N_LO => 2;
use constant _N_HI => 3;
use constant _N_ATTR => 4;
use constant _N_SPLIT => 5;
use constant _N_LEFT => 6;
use constant _N_RIGHT => 7;
use constant _NT_LEAF => 0;
use constant _NT_AXIS => 1;
# Trees are binary (the reference implementation's branching_factor == 2),
# which fixes the depth-budget log base at log(2 * 2). Spelled as the
# exact-double literal rather than log(4) so it is bit-identical to the
# OL_LOG4 literal the C learn path uses regardless of the platform's
# libm rounding -- a one-ulp disagreement would flip `depth < limit`
# split decisions exactly when a tree's count is eta * 4**k (the same
# TWO_PI trick the parent uses for _randn parity).
use constant _LOG4 => unpack( 'd', pack 'd', 1.3862943611198906 );
use constant _LOG2 => log(2);
# DBL_EPSILON, added to the normalisation factor before dividing so a
# just-started model (normaliser 0) yields well-defined scores instead of
# a division by zero -- the same guard the reference implementation uses.
use constant _EPS => 2.220446049250313e-16;
# The online learn/unlearn/score-row XS functions were added to the C
# backend after the batch-scoring ones, so a prebuilt object installed
# from an older release can back $HAS_C while lacking them (the parent
# trusts a flag-matched prebuilt object without inspecting its symbol
# set). Probe once at load: without them, use_c still accelerates the
# packed-snapshot batch scoring -- those functions have been in the
# object all along -- and learning quietly stays pure Perl instead of
# crashing on an undefined XS sub. Rebuilding/reinstalling (or
# IF_RUNTIME_BUILD=1) restores the full set.
use constant _HAS_ONLINE_XS => defined &Algorithm::Classifier::IsolationForest::online_learn_row_xs ? 1 : 0;
=head1 NAME
Algorithm::Classifier::IsolationForest::Online - Online (streaming) Isolation Forest anomaly detection
=head1 SYNOPSIS
use Algorithm::Classifier::IsolationForest::Online;
my $oif = Algorithm::Classifier::IsolationForest::Online->new(
n_trees => 100,
window_size => 2048,
max_leaf_samples => 32,
seed => 42,
);
# stream data through the model; each point is learned and old
# points beyond the window are forgotten automatically
$oif->learn(\@warmup_rows);
# prequential operation: score each point against the model as it
# stood BEFORE that point was learned, then learn it
my $scores = $oif->score_learn(\@new_rows);
# or score without learning
my $scores2 = $oif->score_samples(\@query_rows);
my $labels = $oif->predict(\@query_rows);
# persistence keeps the window, so a reloaded model keeps forgetting
# correctly as the stream continues
$oif->save('oiforest_model.json');
my $resumed = Algorithm::Classifier::IsolationForest::Online->load('oiforest_model.json');
=head1 DESCRIPTION
Implements Online Isolation Forest (Online-iForest; Leveni, Weigert
Cassales, Pfahringer, Bifet & Boracchi 2024 -- see REFERENCES), a
streaming variant of Isolation Forest for data that arrives continuously
and whose distribution may drift. There is no C<fit()>: the model
C<learn>s points as they arrive and, once more than C<window_size> points
have been seen, forgets the oldest point for every new one so the model
always reflects the most recent C<window_size> points of the stream.
Trees never store data points. Each node keeps only a running count of
the points that passed through it and the bounding box of their feature
values. A leaf splits once enough points have accumulated (see
C<max_leaf_samples> and C<growth>); because the actual points are gone,
the split simulates them by sampling uniformly inside the leaf's bounding
box. Forgetting reverses the process: counts are decremented along the
forgotten point's path and a subtree whose count falls below its split
requirement is collapsed back into a leaf.
Scoring follows the classic Isolation Forest intuition -- anomalies
isolate at shallow depth -- but normalises by the depth budget
C<log(n/max_leaf_samples) / log(4)> of the current window rather than the
batch model's C<c(psi)>. Scores are in (0, 1] with high values
anomalous, directly comparable in spirit (though not numerically) to the
parent class's scores.
Both learning and scoring are accelerated through the parent class's
Inline::C backend when it is available; C<use_c> covers them together.
Learning (and the per-row walks inside C<score_learn>) runs in C
directly against the live trees, drawing randomness through the same
generator in the same order as the pure-Perl path -- so, like the
parent's C<fit()>, a C<learn()> with a given seed produces bit-identical
trees whether C<use_c> is on or off (on C<nvsize == 8> perls; wide-NV
perls keep extra low bits in the pure-Perl path). The knob changes
speed, never results.
Batch scoring lazily flattens the mutable trees into the same packed
node layout the batch scorer walks -- online trees are axis-only, and
the online per-leaf depth adjustment rides in the slot the batch packer
uses for its own leaf adjustment -- so C<score_samples>, C<predict>,
C<path_lengths>, C<score_predict_samples>, and C<score_predict_split>
all run through the same C (and OpenMP, when linked) tree walk the
parent uses, with identical results to the pure-Perl fallback. Any
C<learn> invalidates the packed snapshot; the next batch-scoring call
repacks once. C<score_learn> never touches the snapshot: it mutates
the trees after every single point, so its rows are scored by walking
the live trees in C instead.
A model needs to have seen at least C<max_leaf_samples> points before
tree structure exists at all; until then every point scores 1.0. Give
the model a warm-up C<learn()> pass before trusting scores or labels.
Models saved by this class carry their own C<format> tag.
C<< Algorithm::Classifier::IsolationForest->load >> recognises it and
dispatches here, so callers can load either model type through the
parent class.
=head1 GENERAL METHODS
=head2 new(%args)
Inits the object.
- n_trees :: number of isolation trees in the ensemble
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
Learns the passed samples, in order, as the next points of the stream.
Once the model has seen more than C<window_size> points, each learned
point also forgets the oldest retained point, so the model tracks the
most recent C<window_size> points.
The data format matches the parent class's C<fit>: an arrayref of
arrayrefs, each inner arrayref one sample of numeric features. All
samples must have the same feature count; the count is locked in by the
first sample ever learned.
Returns C<$self>, so it chains.
$oif->learn(\@rows);
=cut
sub learn {
my ( $self, $data ) = @_;
croak "learn() expects a non-empty arrayref of samples"
unless ref $data eq 'ARRAY' && @$data;
for my $row (@$data) {
$self->_learn_row( $self->_prep_row( $row, 'learn' ) );
}
return $self;
}
=head2 learn_tagged(\%row or \@rows)
Learns one sample supplied as a hashref of named feature values, or a
whole batch supplied as an arrayref of such hashrefs, in stream order.
The model must have C<feature_names> set. Rows go through
L<tagged_row_to_array|/tagged_row_to_array(\%row, $caller)> (and
therefore through the munger plan when C<mungers> is configured).
Returns C<$self>.
$oif->learn_tagged({ cpu => 0.9, mem => 0.4, disk => 0.1 });
$oif->learn_tagged(\@hashref_rows);
Croaks under the same conditions as
L<tagged_row_to_array|/tagged_row_to_array(\%row, $caller)>, naming the
offending row by index in the batch form.
=cut
sub learn_tagged {
my ( $self, $row ) = @_;
if ( ref $row eq 'ARRAY' ) {
my @rows;
for my $i ( 0 .. $#$row ) {
push @rows, $self->tagged_row_to_array( $row->[$i], "learn_tagged (row $i)" );
}
return $self->learn( \@rows );
}
my $vec = $self->tagged_row_to_array( $row, 'learn_tagged' );
return $self->learn( [$vec] );
} ## end sub learn_tagged
=head2 score_learn(\@data)
Prequential (test-then-train) operation, the usual way to run a streaming
detector: each sample is scored against the model as it stood I<before>
that sample was learned, then learned. Returns an arrayref of anomaly
scores, one per sample, in input order.
Unlike the pure scoring methods this works on a brand-new model too (the
first points of a stream simply score 1.0, as nothing is known yet).
my $scores = $oif->score_learn(\@rows);
=cut
sub score_learn {
my ( $self, $data ) = @_;
croak "score_learn() expects a non-empty arrayref of samples"
unless ref $data eq 'ARRAY' && @$data;
my @scores;
for my $row (@$data) {
my $r = $self->_prep_row( $row, 'score_learn' );
push @scores, $self->_score_row($r);
$self->_learn_row($r);
}
return \@scores;
} ## end sub score_learn
=head2 score_learn_tagged(\%row)
Prequential score-then-learn for a single sample supplied as a hashref of
named feature values. Returns the scalar anomaly score the sample had
before it was learned.
my $score = $oif->score_learn_tagged({ cpu => 0.9, mem => 0.4 });
Croaks under the same conditions as L<tagged_row_to_array|/tagged_row_to_array(\%row, $caller)>.
=cut
sub score_learn_tagged {
my ( $self, $row ) = @_;
my $vec = $self->tagged_row_to_array( $row, 'score_learn_tagged' );
my $result = $self->score_learn( [$vec] );
return $result->[0];
}
=head2 score_samples(\@data)
Returns an arrayref of anomaly scores in (0, 1] without learning
anything. Scores near 1 are strong anomalies (isolated at shallow
depth); scores well below 0.5 are normal.
my $scores = $oif->score_samples(\@data);
=cut
sub score_samples {
my ( $self, $data ) = @_;
$self->_check_learned;
croak "score_samples() expects an arrayref of samples"
unless ref $data eq 'ARRAY';
if ( $self->_ensure_c_trees ) {
my ( $n_pts, $x_packed ) = $self->_pack_input($data);
my $sums_packed = "\0" x ( $n_pts * 8 );
Algorithm::Classifier::IsolationForest::score_all_xs(
$self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
$x_packed, $sums_packed, $n_pts,
$self->{n_features}, scalar @{ $self->{trees} }, $self->{_use_openmp}
);
my $result = [];
Algorithm::Classifier::IsolationForest::finalize_scores_xs( $sums_packed, $n_pts, $self->_score_inv, $result );
return $result;
} ## end if ( $self->_ensure_c_trees )
my $sums = $self->_depth_sums($data);
my $inv = $self->_score_inv;
return [ map { exp( -$_ * $inv ) } @$sums ];
} ## end sub score_samples
=head2 score_sample_tagged(\%row)
Scores a single sample supplied as a hashref of named feature values,
without learning it. Returns a scalar anomaly score in (0, 1].
my $score = $oif->score_sample_tagged({ cpu => 0.9, mem => 0.4 });
Croaks under the same conditions as L<tagged_row_to_array|/tagged_row_to_array(\%row, $caller)>.
=cut
sub score_sample_tagged {
my ( $self, $row ) = @_;
my $vec = $self->tagged_row_to_array( $row, 'score_sample_tagged' );
my $result = $self->score_samples( [$vec] );
return $result->[0];
}
=head2 explain_samples(\@data, %opts)
Explains, per sample, which features drove its anomaly score, without
learning anything -- the streaming counterpart of the parent class's
method of the same name, returning the identical structure (see
C<explain_samples> in L<Algorithm::Classifier::IsolationForest> for the
full description of the output shape and the C<method> option):
my $explanations = $oif->explain_samples(\@data);
my $top = $explanations->[0]{features}[0];
Differences from the batch class:
The default C<ablation> method substitutes per-feature medians of the
currently retained window (there is no fit() to store baselines at;
the window IS the model's view of normal, and it tracks drift for
free). It therefore requires C<< window_size > 0 >> with learned
points and croaks otherwise -- use C<path> on a windowless model.
The C<path> method carries an extra caveat on top of the batch class's
(see the parent POD): online trees are shallow by construction (the
depth budget is C<log(n/max_leaf_samples)/log(4)>) and most of a
sample's anomalousness lives in the per-leaf count adjustment rather
than in which splits it crossed, so path attributions here are coarse.
Treat them as a rough second opinion; prefer C<ablation> whenever a
window exists.
A model that has not yet accumulated tree structure (fewer than
C<max_leaf_samples> points seen) scores everything 1.0 and has no
splits to attribute; every weight comes back 0.
=cut
sub explain_samples {
my ( $self, $data, %opts ) = @_;
$self->_check_learned;
croak "explain_samples() expects a non-empty arrayref of samples"
unless ref $data eq 'ARRAY' && @$data;
my $method = delete $opts{method} // 'ablation';
croak "explain_samples: method must be 'path' or 'ablation'"
unless $method =~ /\A(?:path|ablation)\z/;
croak "explain_samples: unknown option(s): " . join( ', ', sort keys %opts )
if %opts;
return $method eq 'ablation'
? $self->_explain_ablation($data)
: $self->_explain_path($data);
} ## end sub explain_samples
=head2 explain_sample_tagged(\%row, %opts)
Explains a single sample supplied as a hashref of named feature values,
without learning it. Takes the same C<method> option as
L<explain_samples|/explain_samples(\@data, %opts)> and returns the single explanation hashref.
my $e = $oif->explain_sample_tagged({ cpu => 0.9, mem => 0.4 });
Croaks under the same conditions as L<tagged_row_to_array|/tagged_row_to_array(\%row, $caller)>.
=cut
sub explain_sample_tagged {
my ( $self, $row, %opts ) = @_;
my $vec = $self->tagged_row_to_array( $row, 'explain_sample_tagged' );
return $self->explain_samples( [$vec], %opts )->[0];
}
=head2 path_lengths(\@data)
Returns an arrayref of the mean isolation depth per sample across the
trees, for inspection -- the streaming counterpart of the parent class's
method of the same name. Depths include the per-leaf count adjustment.
my $depths = $oif->path_lengths(\@data);
=cut
sub path_lengths {
my ( $self, $data ) = @_;
$self->_check_learned;
croak "path_lengths() expects an arrayref of samples"
unless ref $data eq 'ARRAY';
my $t = scalar @{ $self->{trees} };
if ( $self->_ensure_c_trees ) {
my ( $n_pts, $x_packed ) = $self->_pack_input($data);
my $sums_packed = "\0" x ( $n_pts * 8 );
Algorithm::Classifier::IsolationForest::score_all_xs(
$self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
$x_packed, $sums_packed, $n_pts,
$self->{n_features}, $t, $self->{_use_openmp}
);
my $result = [];
Algorithm::Classifier::IsolationForest::finalize_path_lengths_xs( $sums_packed, $n_pts, $t + 0.0, $result );
return $result;
} ## end if ( $self->_ensure_c_trees )
my $sums = $self->_depth_sums($data);
return [ map { $_ / $t } @$sums ];
} ## end sub path_lengths
=head2 predict(\@data, $threshold)
Returns an arrayref of 0/1 labels for the specified data, without
learning it.
If C<$threshold> is not given, the contamination-learned cutoff is used
when available (learned from the current window on first use -- see
C<contamination> in L<new|/new(%args)>), otherwise 0.5.
Note that absolute score levels depend on C<window_size> and
C<max_leaf_samples> (shallower depth budgets compress scores downward),
so the 0.5 fallback is a blunt default here -- anomalies reliably rank
above normal points, but may sit below 0.5. Setting C<contamination>,
or passing a threshold calibrated from observed scores, is recommended.
my $labels = $oif->predict(\@data);
=cut
sub predict {
my ( $self, $data, $threshold ) = @_;
$self->_check_learned;
$self->_ensure_threshold;
$threshold
= defined $threshold ? $threshold
: defined $self->{threshold} ? $self->{threshold}
: 0.5;
# Fast path: threshold the raw depth sums directly, skipping the
# per-point exp() -- score >= T iff sum <= -log(T)/inv. Only valid
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
# boxes grow in place, so they must never alias a child's). Nodes with
# no box yet (empty leaves) are skipped; (undef, undef) if neither has
# one.
#
# Args:
# $a, $b :: two nodes, in the layout at the top of this file. Either may
# be an empty leaf whose box slots are still undef.
#
# Returns: the two-element list ($lo, $hi) of fresh arrayrefs covering both
# nodes, or (undef, undef) when neither has a box yet.
#
# Example:
# my ( $lo, $hi ) = _box_union( $node->[_N_LEFT], $node->[_N_RIGHT] );
# if ( defined $lo ) { $node->[_N_LO] = $lo; $node->[_N_HI] = $hi }
sub _box_union {
my ( $a, $b ) = @_;
my @boxed = grep { defined $_->[_N_LO] } ( $a, $b );
return ( undef, undef ) unless @boxed;
my $lo = [ @{ $boxed[0][_N_LO] } ];
my $hi = [ @{ $boxed[0][_N_HI] } ];
if ( @boxed == 2 ) {
my ( $blo, $bhi ) = ( $boxed[1][_N_LO], $boxed[1][_N_HI] );
for my $f ( 0 .. $#$lo ) {
$lo->[$f] = $blo->[$f] if $blo->[$f] < $lo->[$f];
$hi->[$f] = $bhi->[$f] if $bhi->[$f] > $hi->[$f];
}
}
return ( $lo, $hi );
} ## end sub _box_union
# (lo, hi) bounding box of a point set; (undef, undef) when empty.
#
# Args:
# $pts :: the points to bound, an arrayref of arrayrefs. May be empty.
#
# Returns: the two-element list ($lo, $hi) of fresh arrayrefs holding the
# per-feature minimum and maximum, or (undef, undef) for an empty set.
#
# Example:
# my ( $lo, $hi ) = _box_of( [ [ 0.9, 0.4 ], [ 0.2, 0.7 ] ] );
# # ( [ 0.2, 0.4 ], [ 0.9, 0.7 ] )
sub _box_of {
my ($pts) = @_;
return ( undef, undef ) unless @$pts;
my $lo = [ @{ $pts->[0] } ];
my $hi = [ @{ $pts->[0] } ];
for my $p (@$pts) {
for my $f ( 0 .. $#$p ) {
$lo->[$f] = $p->[$f] if $p->[$f] < $lo->[$f];
$hi->[$f] = $p->[$f] if $p->[$f] > $hi->[$f];
}
}
return ( $lo, $hi );
} ## end sub _box_of
#-------------------------------------------------------------------------------
# Scoring.
#-------------------------------------------------------------------------------
# Depth of the leaf $x lands in, plus the leaf's own depth budget -- the
# streaming analogue of the batch scorer's c(leaf size) adjustment.
# Scoring tolerates undef cells (mapped to 0), matching the parent class.
#
# Args:
# $x :: one sample, an arrayref of feature values. undef cells are
# allowed and count as 0.
# $node :: the node to start walking from, normally a tree's root. Must
# be defined -- callers skip trees that have not grown one.
#
# Returns: the path length as a float: edges walked plus the leaf's
# _rpl(count), so it is rarely a whole number.
#
# Example:
# $self->_depth_of( [ 0.9, 0.4 ], $self->{trees}[0]{root} ); # e.g. 2.8
sub _depth_of {
my ( $self, $x, $node ) = @_;
my $depth = 0;
while ( $node->[_N_TYPE] ) {
$node = ( $x->[ $node->[_N_ATTR] ] // 0 ) < $node->[_N_SPLIT] ? $node->[_N_LEFT] : $node->[_N_RIGHT];
$depth++;
}
return $depth + $self->_rpl( $node->[_N_COUNT] );
}
# Per-sample depth sums across all trees (tree-outer, sample-inner for
# cache locality, mirroring the parent's pure-Perl loops).
#
# Args:
# $data :: the samples to walk, an arrayref of feature-value arrayrefs
# already through _prep_row or otherwise known dense-ish (undef
# cells count as 0).
#
# Returns: arrayref of per-sample depth sums, positionally matching $data.
# Trees with no root contribute nothing, so a brand-new model yields all
# zeroes.
#
# Example:
# my $sums = $self->_depth_sums( \@rows );
# my $inv = $self->_score_inv;
# my @scores = map { exp( -$_ * $inv ) } @$sums;
sub _depth_sums {
my ( $self, $data ) = @_;
my @sums = (0) x @$data;
for my $tree ( @{ $self->{trees} } ) {
my $root = $tree->{root};
next unless defined $root;
for my $i ( 0 .. $#$data ) {
$sums[$i] += $self->_depth_of( $data->[$i], $root );
}
}
return \@sums;
} ## end sub _depth_sums
# Single-row score against the current model state; used by the
# prequential score_learn loop, where the normaliser moves as points are
# learned and so must be recomputed per row.
#
# Args:
# $r :: one sample, already through _prep_row.
#
# Returns: the anomaly score as a float in (0, 1] -- near 1 for an
lib/Algorithm/Classifier/IsolationForest/Online.pm view on Meta::CPAN
{
score => $scores->[$i],
method => 'path',
features => Algorithm::Classifier::IsolationForest::_credit_to_features( $names, $credit, $x ),
};
} ## end for my $i ( 0 .. $#$data )
return \@out;
} ## end sub _explain_path
# Counterfactual explanation: every row followed by its n_features
# single-feature baseline substitutions, scored as one batch (which
# rides the packed-snapshot C scorer when available). Mirrors the
# parent's _explain_ablation with the window medians as baselines.
#
# Args:
# $data :: the samples to explain, an arrayref of feature-value
# arrayrefs, already prepped by the caller.
#
# Returns: arrayref of one hashref per row, in input order, each holding
# score, method (always 'ablation') and features. Croaks by way of
# _window_baselines when the model has no retained window to take medians
# from.
#
# Example:
# my $out = $self->_explain_ablation( [ [ 8.1, 0.2 ] ] );
# $out->[0]{features}[0]{delta}; # score drop from neutralising it
sub _explain_ablation {
my ( $self, $data ) = @_;
my $nf = $self->{n_features};
my $names = $self->{feature_names};
my $baselines = $self->_window_baselines;
my @batch;
for my $row (@$data) {
push @batch, $row;
for my $f ( 0 .. $nf - 1 ) {
my @variant = @$row;
$variant[$f] = $baselines->[$f];
push @batch, \@variant;
}
}
my $scores = $self->score_samples( \@batch );
my @out;
for my $i ( 0 .. $#$data ) {
my $base = $i * ( $nf + 1 );
my $score = $scores->[$base];
my @deltas = map { $score - $scores->[ $base + 1 + $_ ] } 0 .. $nf - 1;
push @out,
{
score => $score,
method => 'ablation',
features => Algorithm::Classifier::IsolationForest::_deltas_to_features(
$names, \@deltas, $data->[$i], $baselines
),
};
} ## end for my $i ( 0 .. $#$data )
return \@out;
} ## end sub _explain_ablation
# Per-feature medians of the retained window -- the streaming
# equivalent of the batch class's fit-time baselines, and better in one
# way: they track drift for free because the window does. Recomputed
# per explanation call (the window moves with the stream); a sort per
# feature over at most window_size values, dwarfed by the scoring batch
# it feeds. Window rows are always dense (missing => die/zero), so no
# undef handling is needed. Without a retained window there is nothing
# to take a median of.
#
# Args: none beyond the model itself.
#
# Returns: an arrayref of n_features medians taken over the retained
# window. Croaks when the window is empty or absent (window_size 0, or
# nothing learned yet), pointing the caller at method => 'path'.
#
# Example:
# my $baselines = $self->_window_baselines; # [ 0.5, 0.5 ]
sub _window_baselines {
my ($self) = @_;
my $win = $self->{window};
croak "explain_samples: ablation explanations need retained window data "
. "(window_size > 0 and learned points); use method => 'path' instead"
unless ref $win eq 'ARRAY' && @$win;
my $nf = $self->{n_features};
my @baselines;
for my $f ( 0 .. $nf - 1 ) {
my @vals = sort { $a <=> $b } map { $_->[$f] } @$win;
my $k = scalar @vals;
$baselines[$f]
= $k % 2
? $vals[ int( $k / 2 ) ]
: ( $vals[ $k / 2 - 1 ] + $vals[ $k / 2 ] ) / 2.0;
}
return \@baselines;
} ## end sub _window_baselines
#-------------------------------------------------------------------------------
# C-accelerated scoring.
#
# The parent class's Inline::C scorer walks immutable packed node buffers;
# online trees mutate on every learned point. The bridge is a lazily
# built snapshot: the first scoring call after any mutation flattens the
# live trees into the parent's packed node layout (below) and every
# scoring call until the next mutation reuses it. _learn_row -- the one
# choke point all mutations flow through -- drops the snapshot.
#
# Online trees are axis-only, so they map onto the parent's 6-double node
# records directly:
#
# leaf: [0, count, _rpl(count), 0, 0, 0]
# axis: [1, attr, split, li, ri, 0]
#
# The parent packs c(leaf size) into slot 2 and its C walker returns
# depth + slot2 at a leaf; packing the online depth-budget adjustment
# _rpl(count) there instead makes score_all_xs compute exactly the
# pure-Perl _depth_of value, so every downstream C helper (finalize_*,
# predict_sums_xs, score_predict_*) applies unchanged. The per-tree
# coefficient buffers are empty -- there are no oblique nodes -- and only
# exist because score_all_xs expects them.
#
( run in 1.043 second using v1.01-cache-2.11-cpan-9789f410c06 )