Algorithm-Classifier-IsolationForest
view release on metacpan or search on metacpan
t/39-online-stream.t view on Meta::CPAN
cmp_ok( $a_after, '>', $b_after, 'phase B: (0,0) is now the anomaly' );
cmp_ok( $a_after, '>', $a_before, '(0,0) became more anomalous after the drift' );
cmp_ok( $b_after, '<', $b_before, '(6,6) became less anomalous after the drift' );
# Forgetting kept the model's size bounded rather than accreting
# structure from both phases.
is( $m->window_count, 512, 'window stayed capped across the drift' );
for my $tree ( @{ $m->{trees} } ) {
is( $tree->{count}, 512, 'per-tree count stayed pinned to the window' );
}
}; ## end 'drift adaptation via the sliding window' => sub
subtest 'forgetting collapses structure' => sub {
srand(8);
my $m = $class->new( seed => 9, n_trees => 20, window_size => 128, max_leaf_samples => 16 );
$m->learn( cluster( 400, 0 ) );
my $count_nodes;
$count_nodes = sub {
my ($node) = @_;
return 0 unless defined $node;
return 1 unless $node->[0];
return 1 + $count_nodes->( $node->[6] ) + $count_nodes->( $node->[7] );
};
my $total = 0;
$total += $count_nodes->( $_->{root} ) for @{ $m->{trees} };
ok( $total > 0, 'trees have structure' );
# The depth budget bounds the node count: a binary tree limited to
# depth ceil(log(window/eta)/log(4)) + 1 levels of splitting can never
# exceed 2**(depth+2) nodes; with drift churn the practical count sits
# far below even that. This catches collapse failing outright (node
# counts growing with the stream instead of the window).
my $depth_budget = log( 128 / 16 ) / log(4);
my $per_tree_cap = 2**( int($depth_budget) + 3 );
cmp_ok( $total / 20, '<=', $per_tree_cap, "average nodes per tree bounded by the depth budget" );
# Stream a long tail and confirm the size stays flat rather than
# accumulating.
$m->learn( cluster( 1000, 0 ) );
my $total2 = 0;
$total2 += $count_nodes->( $_->{root} ) for @{ $m->{trees} };
cmp_ok( $total2 / 20, '<=', $per_tree_cap, 'node count still bounded after a long stream' );
}; ## end 'forgetting collapses structure' => sub
subtest 'contamination threshold' => sub {
srand(9);
my $m = $class->new(
seed => 11,
n_trees => 50,
window_size => 512,
max_leaf_samples => 32,
contamination => 0.05,
);
$m->learn( cluster( 600, 0 ) );
is( $m->decision_threshold, undef, 'threshold not learned until a predict-family call' );
my $labels = $m->predict( [ [ 0, 0 ], [ 7, 7 ] ] );
ok( defined $m->decision_threshold, 'first predict learned the threshold from the window' );
is_deeply( $labels, [ 0, 1 ], 'inlier passes, outlier flagged at the learned threshold' );
# The learned cutoff should flag roughly the contamination fraction of
# the window itself.
my $flags = $m->predict( $m->{window} );
my $rate = 0;
$rate += $_ for @$flags;
$rate /= scalar @$flags;
cmp_ok( $rate, '>=', 0.02, 'window flag rate not far below contamination' );
cmp_ok( $rate, '<=', 0.09, 'window flag rate not far above contamination' );
# relearn_threshold tracks drift.
my $old_thr = $m->decision_threshold;
$m->learn( cluster( 600, 6 ) );
my $ret = $m->relearn_threshold;
is( $ret, $m, 'relearn_threshold chains' );
isnt( $m->decision_threshold, $old_thr, 'threshold moved with the stream' );
is( $m->predict( [ [ 6, 6 ] ] )->[0], 0, 'post-drift centre passes at the refreshed threshold' );
ok(
!eval { $class->new( n_trees => 5 )->relearn_threshold; 1 },
'relearn_threshold croaks without contamination'
);
}; ## end 'contamination threshold' => sub
subtest 'window_size 0 disables forgetting' => sub {
srand(10);
my $m = $class->new( seed => 13, n_trees => 20, window_size => 0, max_leaf_samples => 16 );
$m->learn( cluster( 300, 0 ) );
is( $m->window_count, 0, 'no window is retained' );
is( $m->seen, 300, 'stream counted' );
for my $tree ( @{ $m->{trees} } ) {
is( $tree->{count}, 300, 'trees learned the whole stream' );
}
my $scores = $m->score_samples( [ [ 0, 0 ], [ 8, 8 ] ] );
cmp_ok( $scores->[1], '>', $scores->[0], 'still separates outliers' );
my $c = $class->new( n_trees => 5, window_size => 0, contamination => 0.05 );
$c->learn( cluster( 100, 0 ) );
ok( !eval { $c->relearn_threshold; 1 }, 'relearn_threshold without a window croaks without data' );
ok( eval { $c->relearn_threshold( cluster( 100, 0 ) ); 1 }, '... but accepts caller-supplied data' )
or diag $@;
ok( defined $c->decision_threshold, 'threshold learned from the supplied data' );
}; ## end 'window_size 0 disables forgetting' => sub
subtest 'persistence round trip' => sub {
my $dir = tempdir( CLEANUP => 1 );
srand(11);
my $m = $class->new(
seed => 17,
n_trees => 30,
window_size => 256,
max_leaf_samples => 16,
contamination => 0.05,
feature_names => [ 'x', 'y' ],
);
$m->learn( cluster( 400, 0 ) );
$m->predict( [ [ 0, 0 ] ] ); # force the threshold to exist
my @grid = map { [ $_ / 2 - 3, $_ / 3 - 2 ] } 0 .. 20;
my $before = $m->score_samples( \@grid );
my $path = "$dir/oiforest_model.json";
$m->save($path);
ok( -s $path, 'model file written' );
my $re = $class->load($path);
my $after = $re->score_samples( \@grid );
for my $i ( 0 .. $#grid ) {
cmp_ok( abs( $before->[$i] - $after->[$i] ), '<', 1e-9, "grid point $i scores match after reload" );
( run in 0.789 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )