Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

package Algorithm::Classifier::IsolationForest;

use strict;
use warnings;
use Carp         qw(croak);
use Config       ();
use List::Util   qw(min);
use Scalar::Util qw(looks_like_number);
use POSIX        qw(ceil);
use JSON::PP     ();
use File::Slurp  qw(read_file write_file);

our $VERSION = '0.7.0';

use constant EULER => 0.5772156649015329;

# Narrowed to C double precision so _randn() multiplies by the exact
# constant _c_randn() uses.  A no-op on nvsize == 8 perls.
use constant TWO_PI => unpack( 'd', pack 'd', 6.283185307179586 );

# Node-type tags stored in index 0 of every tree node arrayref.
# 0 is falsy, so  while ($node->[0])  acts as  while (!leaf).
use constant _NODE_LEAF    => 0;
use constant _NODE_AXIS    => 1;
use constant _NODE_OBLIQUE => 2;

# The Inline::C tree builder computes everything in C doubles.  On a perl
# whose NV is wider than a double (-Duselongdouble / -Dusequadmath) the
# pure-Perl builder keeps extra low bits at every step, so the two
# backends would stop producing bit-identical trees for the same seed
# (the parity t/03-fit-determinism.t checks).  _NV_IS_DOUBLE guards
# narrowing statements wherever the pure-Perl builder computes a value
# that gets STORED in a tree (split points, hyperplane coefficients and
# offsets, impute fills), rounding at the same points the C builder
# rounds.  It is compile-time true on nvsize == 8 perls, so there the
# guarded statements are optimised away and cost nothing.
#
# The row-partition loops (v < split, dot <= b) are deliberately NOT
# narrowed: with both operands already double-exact an axis comparison
# is identical anyway, and an oblique dot product accumulated in a wider
# NV flips a comparison only when |dot - b| falls inside the NV-vs-double
# rounding gap (~1e-19 relative) -- negligible, and those are the hot
# loops.
use constant _NV_IS_DOUBLE => $Config::Config{nvsize} == 8;

# Round an NV to C double precision.  Only ever reached on wide-NV
# perls -- see _NV_IS_DOUBLE.
#
# Args:
#   $nv :: any number.  On a -Duselongdouble / -Dusequadmath perl it may
#          carry more precision than a C double can hold.
#
# Returns: the same number rounded to the nearest IEEE 754 double.  A no-op
# for a value that is already double-exact.
#
# Example:
#   _to_double( $lo + rand() * ( $hi - $lo ) );   # what the C builder stores
sub _to_double { unpack 'd', pack 'd', $_[0] }

# ---------------------------------------------------------------------------
# Optional Inline::C accelerator for the scoring hot path.
#
# pack_input_xs(data_sv, out_sv, n_pts, n_feats, miss_mode, fill_sv)
#     Walks the Perl arrayref-of-arrayrefs and writes a packed double buffer
#     into out_sv.  Replaces the dominant per-call Perl map-pack loop.
#     miss_mode selects how an undef cell is packed: 0 => 0.0, 1 => the
#     per-feature fill from fill_sv (impute), 2 => NaN (nan strategy).
#

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN


B<Census> -- count the rows (this is the true C<n> that fixes C<psi =
min(sample_size, n)>) and pin the feature width.  By default this is an
I<index> pass (see C<index> below): a fast block-scan that also records each
data row's byte offset.

=item 2.

B<Gather> -- retain only the rows the trees actually sampled (chosen in
bounded memory via Floyd's algorithm), then build each tree from its
sub-sample exactly as L</fit> would.  With the offset table from pass 1 this
B<seeks straight to the sampled rows> instead of re-scanning the file.

=item 3.

B<Threshold> (only when C<contamination> is set) -- score every row against
the built forest and place the cut at the contamination boundary.  The cut
is B<exact>, not estimated: a single min-heap of the C<k+1> largest scores
(C<k = round(contamination * n)>) captures everything
L</decision_threshold> selection needs, with a rare extra pass to resolve a
tie block straddling the boundary.  Under C<< use_c => 1 >> (the default when
the C backend is present) this pass runs through the C scorer, so it stays
fast even over millions of rows.

=back

Neither census variant parses cells into numbers or checks for missing
values: only the rows that actually train (via gather) or are scored (the
threshold pass) are validated, so a malformed or missing cell in a
never-used row is not reported.  In particular, under C<< missing => 'die' >>
a missing cell is rejected exactly when it appears in a sampled training row.

Because rows are addressed by their position across passes, C<$path> must be
a stable, re-readable file (not a pipe or C<STDIN>).

The first line is skipped as a header when it holds any non-numeric text (a
feature-name row) or exactly matches the model's stored C<feature_names> --
data rows contain only numbers and empty cells.  Pass C<< header => 1 >> to
force the first line to be skipped even when it is all-numeric.

Options:

=over 4

=item * C<< header => 1 >> -- always skip the first line as a column header
(otherwise a header is auto-detected as described above).

=item * C<< index => 0 >> -- disable the offset-index pass and use the
streaming two-pass reader instead.  The index is on by default: it makes
gather a random-access read of just the sampled rows rather than a second
full scan, but costs an C<8 * n>-byte offset table.  When that table would
exceed C<index_max> it is dropped automatically and the fit falls back to
streaming.  (In index mode a blank line is one that is empty or contains only
whitespace, judged cheaply; pass C<< index => 0 >> for files where that
matters.)

=item * C<< index_max => bytes >> -- ceiling on the offset table (default
256 MiB).  Above it, the index is abandoned mid-scan and gather streams.

=item * C<< c_scan => 0 >> -- validate each scored cell with
C<looks_like_number> in the threshold pass instead of letting the C packer
coerce it.  C<c_scan> is on by default and takes effect only on the C
mean-voting scoring path, where it is a large saving over millions of rows;
it produces the identical result on valid data, but coerces a non-numeric
scored cell to C<0.0> (nudging the threshold) rather than dying on it.

=back

Every column is a feature; an empty cell is treated as a missing value and
handled by the model's C<missing> strategy.  The resulting model is
identical in shape to one built by L</fit> and is fully deterministic given
the same file, C<seed>, and parameters (though not bit-identical to
L</fit>, whose RNG stream interleaves sampling and tree-building
differently).

Current limitations: C<mungers> and tagged/named columns are not supported
(load such data through L<fit_tagged|/fit_tagged(\@rows)>), and the trees are always built
serially in pure Perl -- C<parallel_fit>/C<use_openmp_fit> are ignored for
the build, though scoring is still accelerated when C<use_c> is on.

    my $iforest = Algorithm::Classifier::IsolationForest->new(
        n_trees       => 100,
        sample_size   => 256,
        contamination => 0.01,
        seed          => 42,
    );
    $iforest->fit_from_csv('huge.csv', header => 1);

=cut

sub fit_from_csv {
	my ( $self, $path, %opt ) = @_;

	croak "fit_from_csv() requires a path to a CSV file"
		unless defined $path && length $path;
	croak "fit_from_csv(): '$path' is not a readable file"
		unless -f $path && -r _;
	croak "fit_from_csv() does not support munger models; " . "load the data through fit_tagged/fit instead"
		if _plan($self);

	# Decide once whether the first line is a header, so every pass skips the
	# same line and row indices stay aligned.  header => 1 forces it; otherwise
	# a first line carrying any non-numeric text (or matching stored
	# feature_names) is a header -- data rows hold only numbers and blanks.
	my $skip_first = $self->_detect_header( $path, $opt{header} );

	# ---- Pass 1: census.  Establish the true row count (which fixes psi) and
	# pin the feature width.  Neither census variant parses cells into numbers
	# or checks for missing values -- only the rows that actually train
	# (_numify_row, in gather) or get scored (the threshold pass) are validated,
	# so a malformed or missing cell in a never-sampled row is not seen.
	#
	# By default the census is an "index" pass: a block-scan that also records
	# each data row's byte offset, letting Pass 2 seek straight to the sampled
	# rows instead of re-scanning the whole file.  The offset table costs 8*n
	# bytes; when that would top index_max (or index => 0 was passed) it is not
	# built and both passes fall back to the streaming reader.
	my $use_index = exists $opt{index}      ? $opt{index}     : 1;
	my $index_max = defined $opt{index_max} ? $opt{index_max} : ( 256 * 1024 * 1024 );

	my ( $n, $n_features, $offsets );

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

			unless $want == $n_features;
	}

	$self->{n_features} = $n_features;
	my ( $psi, $limit ) = $self->_resolve_geometry( $n, $n_features );

	# ---- Choose which row indices each tree trains on: an independent,
	# uniform psi-subset drawn without replacement.  Floyd's algorithm draws
	# each subset in O(psi) memory, never materialising the 0..n-1 index
	# vector _subsample() uses -- for an out-of-core n that vector would not
	# fit either.  %want maps each needed row index to the (tree, slot) pairs
	# awaiting it, so a row several trees picked is gathered once and shared.
	srand( $self->{seed} ) if defined $self->{seed};
	my @tree_idx;    # tree => [ chosen row indices ]
	my %want;        # row index => [ [tree, slot], ... ]
	for my $t ( 0 .. $self->{n_trees} - 1 ) {
		my @idx = _sample_indices_distinct( $n, $psi );
		$tree_idx[$t] = \@idx;
		push @{ $want{ $idx[$_] } }, [ $t, $_ ] for 0 .. $#idx;
	}

	# ---- Pass 2: gather the sampled rows (validated + coerced by _numify_row).
	# With an offset table this seeks straight to them; otherwise it re-scans.
	my $pool
		= $offsets
		? $self->_gather_indexed( $path, $offsets, \%want )
		: $self->_gather_stream( $path, $skip_first, \%want );

	# Baselines for ablation explanations, learned from the gathered
	# sub-sample -- like the impute fill below, the only rows bounded-
	# memory fitting ever holds.  Before _apply_missing_to_pool, which
	# densifies the pool in place (the medians must come from present
	# values only, matching fit()).  Median is an exact order statistic,
	# so the hash-order values() walk cannot change the result.
	$self->{feature_baselines} = $self->_compute_feature_baselines( [ values %$pool ] );

	# Apply the missing-value strategy to the retained rows (see fit()'s
	# _prepare_fit_data; the impute fill is learned from this training
	# sub-sample, the only value bounded-memory fitting can compute).
	$self->_apply_missing_to_pool($pool);

	# ---- Build. Each tree trains on its gathered sub-sample -- structurally
	# identical to fit()'s per-tree _build_tree(_subsample(...)).
	my @trees;
	for my $t ( 0 .. $self->{n_trees} - 1 ) {
		my $sample = [ @{$pool}{ @{ $tree_idx[$t] } } ];
		push @trees, $self->_build_tree( $sample, 0, $limit );
	}
	$self->{trees} = \@trees;

	# Repack the just-built trees for the C scorer up front.  fit() scores its
	# learned threshold pure-Perl because its training set is small; the
	# streaming contamination pass here may score millions of rows, so paying
	# one repack now lets that pass run through the C path instead (bit-
	# identical result, minutes -> seconds).  The delete first clears any stale
	# buffers from a prior fit so the repack reflects the new forest.
	delete @$self{qw(_c_nodes _c_coef_idx _c_coef_val)};
	$self->_rebuild_c_trees() if $self->{_use_c};

	# c_scan (default on): let the C scorer's packer coerce raw cells via SvNV
	# in the contamination pass instead of validating each with looks_like_number
	# in Perl -- a large saving over millions of rows.  It only takes effect on
	# the C mean-voting path; see _stream_scores.
	my $c_scan = exists $opt{c_scan} ? $opt{c_scan} : 1;
	$self->_learn_contamination_threshold_streaming( $path, $skip_first, $n, $c_scan )
		if defined $self->{contamination};

	return $self;
} ## end sub fit_from_csv

=head2 pack_data(\@data)

Returns an opaque, blessed wrapper around the input dataset that the
scoring methods can use directly, skipping the per-call work of walking
the arrayref-of-arrayrefs and converting each cell into a double.  At
high feature counts this is a meaningful win when the same dataset is
scored repeatedly (e.g. interactive threshold tuning, dashboards,
plotting that updates as parameters change).

Requires the Inline::C backend; croaks if C<use_c> is false.

    my $packed = $forest->pack_data(\@data);

    # Now any of these accept either an arrayref or the packed wrapper:
    my $scores = $forest->score_samples($packed);
    my $flags  = $forest->predict($packed, 0.6);
    my ($s, $l) = $forest->score_predict_split($packed);

The wrapper has C<n_pts> and C<n_feats> accessors for introspection.
The feature count is matched against the model on every call; passing a
packed dataset built for a different feature count is a fatal error.

=cut

=head2 path_lengths(\@data)

Returns an arrayref of the mean isolation depth per sample, for inspection.

    my $lengths = $forest->path_lengths(\@data);

    print "x, y, length\n";

    my $int=0;
    while (defined($data[$int])) {
        print $data[$int][0].', '.$data[$int][1].', '.$lengths->[$int]."\n";

        $int++;
    }

=cut

sub path_lengths {
	my ( $self, $data ) = @_;
	$self->_check_fitted;
	my $trees = $self->{trees};
	my $t     = scalar @$trees;

	if ( $self->{_use_c} && $self->{_c_nodes} ) {
		my ( $n_pts, $nf, $x_packed ) = $self->_resolve_input($data);
		my $sums_packed = "\0" x ( $n_pts * 8 );
		score_all_xs(

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

			= defined $self->{extension_level}
			? $self->{extension_level}
			: $max_ext;
		$ext                          = 0        if $ext < 0;
		$ext                          = $max_ext if $ext > $max_ext;
		$self->{extension_level_used} = $ext;
	} else {
		$self->{extension_level_used} = undef;
	}

	# Height limit: the average tree height ceil(log2(psi)). Past this depth the
	# remaining points are scored using the c(size) adjustment instead.
	my $limit
		= defined $self->{max_depth}
		? $self->{max_depth}
		: ceil( log($psi) / log(2) );
	$limit = 1 if $limit < 1;
	$self->{max_depth_used} = $limit;

	return ( $psi, $limit );
} ## end sub _resolve_geometry

#-------------------------------------------------------------------------------
# fit_from_csv() machinery.
#-------------------------------------------------------------------------------

# Inspect the first non-blank line and report whether it is a header to skip
# rather than train on.  $forced (the header => option) makes it unconditional;
# otherwise a line is a header when it carries any non-numeric text -- data rows
# hold only numbers and empty cells -- or exactly matches stored feature_names.
#
# Args:
#   $path :: path to the CSV file, which must be readable.
#   $forced :: the caller's header => option.  True skips detection and
#              answers yes; undef or false runs the heuristic.
#
# Returns: 1 when the first non-blank line is a header to skip, 0 otherwise.
# An empty file answers 0 and lets the census report the real problem.
#
# Example:
#   $self->_detect_header( 'train.csv', undef );   # 1 for "cpu,mem,disk"
#   $self->_detect_header( 'train.csv', undef );   # 0 for "0.9,0.4,0.1"
sub _detect_header {
	my ( $self, $path, $forced ) = @_;
	open my $fh, '<', $path
		or croak "fit_from_csv(): cannot open '$path': $!";
	my $first;
	while ( defined( my $line = <$fh> ) ) {
		$line =~ s/\r?\n\z//;
		next if $line =~ /^\s*$/;
		$first = $line;
		last;
	}
	close $fh;
	return 0 unless defined $first;    # empty file; the census will report it
	return 1 if $forced;

	my @f = split /,/, $first, -1;
	for my $x (@f) {
		next if !length $x;                       # empty cell: fine in data
		return 1 unless looks_like_number($x);    # text: it is a header
	}

	# All-numeric first line: only a header if it reproduces feature_names.
	my $names = $self->{feature_names};
	if ( ref $names eq 'ARRAY' && @$names == @f ) {
		my $match = grep { $f[$_] eq $names->[$_] } 0 .. $#f;
		return 1 if $match == @f;
	}
	return 0;
} ## end sub _detect_header

# Return a closure that yields ($row, $line_number) per non-blank CSV line and
# an empty list at EOF.  An empty cell becomes undef (the missing-value marker).
# With $parse true each non-empty cell is validated and coerced to a number (a
# non-numeric cell dies); with $parse false the cells are left as raw strings --
# the cheap mode the census uses, which needs only the column count and empties.
# When $skip_first is set the first non-blank line (a header) is dropped.  Blank
# lines are skipped, so a row's index is its position among the non-blank data
# lines -- stable across passes as long as the file does not change under us.
#
# Args:
#   $path :: path to the CSV file, which must be readable.
#   $skip_first :: true to drop the first non-blank line as a header.
#   $parse :: true to validate and numify each non-empty cell (croaking on a
#             non-numeric one), false to hand back raw strings.
#
# Returns: a closure.  Each call returns the two-element list
# (\@fields, $line_number) for the next data line, or the empty list at EOF
# (where it also closes the handle).  @fields holds numbers under
# $parse, strings otherwise, with undef for an empty cell.
#
# Example:
#   my $reader = $self->_csv_reader( 'train.csv', 1, 1 );
#   while ( my ( $row, $line ) = $reader->() ) {
#       # $row = [ 0.9, undef, 0.1 ] for "0.9,,0.1" on line $line
#   }
sub _csv_reader {
	my ( $self, $path, $skip_first, $parse ) = @_;
	open my $fh, '<', $path
		or croak "fit_from_csv(): cannot open '$path': $!";
	my $line_no = 0;
	my $skipped = 0;
	return sub {
		while ( defined( my $line = <$fh> ) ) {
			$line_no++;
			$line =~ s/\r?\n\z//;
			next if $line =~ /^\s*$/;
			if ( $skip_first && !$skipped ) { $skipped = 1; next; }
			my @fields = split /,/, $line, -1;
			for my $f (@fields) {
				if ( !length $f ) { $f = undef; next; }
				next unless $parse;
				croak "fit_from_csv(): line $line_no value '$f' is not a number"
					unless looks_like_number($f);
				$f += 0;
			}
			return ( \@fields, $line_no );
		} ## end while ( defined( my $line = <$fh> ) )
		close $fh;
		return;
	}; ## end sub
} ## end sub _csv_reader

# Validate and coerce a raw row (from a parse => 0 reader) into numbers in
# place: defined cells must look like numbers.  An undef cell (empty CSV marker)
# passes through for zero/impute/nan, but croaks under the 'die' strategy -- so
# 'die' rejects a missing value exactly when it lands in a sampled training row.
# $where names the row for error messages.  Only the rows a fit keeps are run
# through here, which is why a bad cell elsewhere is never reported.
#
# Args:
#   $row :: arrayref of raw cells from a parse => 0 reader -- strings, with
#           undef for the empty cells.  Modified in place.
#   $where :: a phrase naming the row for croak messages, e.g. "line 42" or
#             "sampled row 17".
#
# Returns: nothing.  $row's defined cells come back as numbers; undef cells
# stay undef unless missing => 'die', which croaks instead.
#
# Example:
#   my $row = [ '0.9', undef, '0.1' ];
#   $self->_numify_row( $row, 'line 42' );   # [ 0.9, undef, 0.1 ]
sub _numify_row {
	my ( $self, $row, $where ) = @_;
	my $die = $self->{missing} eq 'die';
	for my $f (@$row) {
		if ( !defined $f ) {
			croak "fit_from_csv(): missing value in $where; construct with "
				. "missing => 'zero', 'impute', or 'nan' to train on data "
				. "with missing values"
				if $die;
			next;
		}
		croak "fit_from_csv(): $where value '$f' is not a number"
			unless looks_like_number($f);
		$f += 0;
	} ## end for my $f (@$row)
	return;
} ## end sub _numify_row

# Streaming census (index => 0 or offset table over budget): count the data
# rows and pin the feature width via the cheap parse => 0 reader.  No cell
# validation -- that is deferred to the rows that train or get scored.
#
# Args:
#   $path :: path to the CSV file, which must be readable.
#   $skip_first :: true to drop the first non-blank line as a header.
#
# Returns: the two-element list ($n, $nf) -- the data row count and the
# feature width taken from the first data row.  Both undef-free, though $n
# is 0 and $nf undef for a file with no data rows.  Croaks on a row whose
# column count disagrees with the first row's.
#
# Example:
#   my ( $n, $nf ) = $self->_census_stream( 'train.csv', 1 );   # ( 1_000_000, 4 )
sub _census_stream {
	my ( $self, $path, $skip_first ) = @_;
	my $reader = $self->_csv_reader( $path, $skip_first, 0 );
	my ( $n, $nf ) = ( 0, undef );
	while ( my ( $row, $line ) = $reader->() ) {
		$nf //= scalar @$row;
		croak "fit_from_csv(): line $line of '$path' has " . scalar(@$row) . " columns but expected $nf"
			unless @$row == $nf;
		$n++;
	}
	return ( $n, $nf );
} ## end sub _census_stream

# Streaming gather (no offset table): re-scan the file, numifying only the
# sampled rows.  Companion to _census_stream.
#
# Args:
#   $path :: path to the CSV file, which must be readable.
#   $skip_first :: true to drop the first non-blank line as a header.
#   $want :: hashref used as a set -- its keys are the data row indices to
#            keep, counting from 0 over the non-blank data lines.
#
# Returns: hashref keyed by the same indices, each value the numified row as
# an arrayref.  Indices absent from the file simply do not appear.
#
# Example:
#   my $pool = $self->_gather_stream( 'train.csv', 1, { 0 => 1, 7 => 1 } );
#   # { 0 => [ 0.9, 0.4 ], 7 => [ 1.2, 0.3 ] }
sub _gather_stream {
	my ( $self, $path, $skip_first, $want ) = @_;
	my $reader = $self->_csv_reader( $path, $skip_first, 0 );
	my %pool;
	my $i = 0;
	while ( my ( $row, $line ) = $reader->() ) {
		if ( exists $want->{$i} ) {
			$self->_numify_row( $row, "line $line" );
			$pool{$i} = $row;
		}
		$i++;
	}

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

	my @heap;
	my $cap = $k + 1;
	$self->_stream_scores(
		$path,
		$skip_first,
		sub {
			my $s = $_[0];
			if    ( @heap < $cap )  { _heap_push( \@heap, $s ) }
			elsif ( $s > $heap[0] ) { _heap_replace_root( \@heap, $s ) }
		},
		$c_scan
	);

	my @desc = sort { $b <=> $a } @heap;    # k+1 largest, descending
	my $v    = $desc[ $k - 1 ];             # k-th largest
	my $lo   = $desc[$k];                   # (k+1)-th largest

	# Clean gap at the boundary: cut midway, exactly as _threshold_from_ranked.
	if ( $lo < $v ) {
		$self->{threshold} = ( $v + $lo ) / 2.0;
		return;
	}

	# A tie block of value $v straddles rank k.  Reproduce _threshold_from_ranked's
	# tie branch: locate the block's edges (i = first index at $v, j = first
	# index below it) and the neighbouring scores with one more pass.
	my ( $cnt_gt, $cnt_eq, $above, $below ) = ( 0, 0, undef, undef );
	$self->_stream_scores(
		$path,
		$skip_first,
		sub {
			my $s = $_[0];
			if ( $s > $v ) {
				$cnt_gt++;
				$above = $s if !defined $above || $s < $above;    # smallest > $v
			} elsif ( $s == $v ) {
				$cnt_eq++;
			} else {
				$below = $s if !defined $below || $s > $below;    # largest < $v
			}
		},
		$c_scan
	);

	my $i = $cnt_gt;              # first rank holding $v
	my $j = $cnt_gt + $cnt_eq;    # first rank below $v
	if ( $i > 0 && ( $k - $i ) < ( $j - $k ) ) {
		$self->{threshold} = ( $above + $v ) / 2.0;    # exclude the block
	} elsif ( $j < $n ) {
		$self->{threshold} = ( $v + $below ) / 2.0;    # include the block
	} else {
		$self->{threshold} = $v - 1e-9;                # block runs to the end
	}
	return;
} ## end sub _learn_contamination_threshold_streaming

# Stream the CSV, score rows in flat batches (through the same mean/majority
# path score_samples/predict use), and invoke $cb->($score) for each row.
# c_scan fast path: when scoring runs through the C packer (mean voting, use_c,
# trees packed) the packer coerces each cell via SvNV, so the reader can run
# raw (parse => 0) and skip the Perl looks_like_number validation.  Every other
# path parses (parse => 1) so its Perl scorer sees real numbers.
#
# Args:
#   $path :: path to the CSV file, which must be readable.
#   $skip_first :: true to drop the first non-blank line as a header.
#   $cb :: coderef invoked once per data row as $cb->($score), in file order.
#          The score is the mean-mode anomaly score, or the majority pivot
#          under voting => 'majority'.
#   $c_scan :: true to allow the raw-cell fast path described above.
#
# Returns: nothing; everything is delivered through $cb.
#
# Example:
#   my $max;
#   $self->_stream_scores( 'train.csv', 1,
#       sub { $max = $_[0] if !defined $max || $_[0] > $max }, 1 );
sub _stream_scores {
	my ( $self, $path, $skip_first, $cb, $c_scan ) = @_;
	my $majority = $self->{voting} eq 'majority' ? 1 : 0;
	my $c_coerce = $c_scan && $self->{_use_c} && $self->{_c_nodes} && !$majority;
	my $reader   = $self->_csv_reader( $path, $skip_first, $c_coerce ? 0 : 1 );

	# The c_coerce path lets the C packer turn any non-numeric cell into 0.0 via
	# SvNV; silence the per-cell "isn't numeric" warnings that intentional
	# coercion raises (clean data produces none).  Other warnings pass through.
	local $SIG{__WARN__};
	$SIG{__WARN__} = sub { $_[0] =~ /isn't numeric/ or warn $_[0] }
		if $c_coerce;

	my @batch;
	my $flush = sub {
		return unless @batch;
		my $scores
			= $majority
			? $self->_majority_pivot_scores( \@batch )
			: $self->score_samples( \@batch );
		$cb->($_) for @$scores;
		@batch = ();
	};
	while ( my ($row) = $reader->() ) {
		push @batch, $row;
		$flush->() if @batch >= 8192;
	}
	$flush->();
	return;
} ## end sub _stream_scores

# Minimal binary min-heap over a plain arrayref (root = smallest).  Paired
# with _heap_replace_root to keep the k+1 largest scores of a stream in
# bounded memory -- push until full, then replace the root whenever a
# bigger value turns up.
#
# Append a value and sift it up into place.
#
# Args:
#   $h :: the heap, an arrayref maintained by these two functions alone.
#         Modified in place.
#   $x :: the number to insert.
#
# Returns: nothing.  $h->[0] is the smallest value held afterwards.



( run in 1.693 second using v1.01-cache-2.11-cpan-4ab04211f4c )