Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

t/39-online-stream.t  view on Meta::CPAN

#!perl
# 39-online-stream.t
#
# Streaming behaviour of Algorithm::Classifier::IsolationForest::Online:
# drift adaptation through the sliding window (learning AND forgetting),
# subtree collapse bookkeeping, contamination thresholds, unbounded
# (window_size 0) operation, and persistence -- including resuming the
# stream after a save/load round trip and loading through the parent
# class's format dispatch.

use strict;
use warnings;
use Test::More;

use Algorithm::Classifier::IsolationForest         ();
use Algorithm::Classifier::IsolationForest::Online ();
use File::Temp                                     qw(tempdir);

my $class = 'Algorithm::Classifier::IsolationForest::Online';

use constant PI => 3.14159265358979;

sub gaussian {
	my ( $mu, $sigma ) = @_;
	my $u1 = rand() || 1e-12;
	my $u2 = rand();
	my $z  = sqrt( -2 * log($u1) ) * cos( 2 * PI * $u2 );
	return $mu + $sigma * $z;
}

sub cluster {
	my ( $n, $mu ) = @_;
	return [ map { [ gaussian( $mu, 1 ), gaussian( $mu, 1 ) ] } 1 .. $n ];
}

subtest 'drift adaptation via the sliding window' => sub {
	srand(7);
	my $m = $class->new( seed => 5, n_trees => 50, window_size => 512, max_leaf_samples => 32 );

	# Phase A: the stream sits at (0, 0).
	$m->learn( cluster( 600, 0 ) );
	my ( $a_before, $b_before ) = @{ $m->score_samples( [ [ 0, 0 ], [ 6, 6 ] ] ) };
	cmp_ok( $b_before, '>', $a_before, 'phase A: (6,6) is the anomaly' );

	# Phase B: the stream drifts to (6, 6); phase A ages out of the window.
	$m->learn( cluster( 600, 6 ) );
	my ( $a_after, $b_after ) = @{ $m->score_samples( [ [ 0, 0 ], [ 6, 6 ] ] ) };
	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;

t/39-online-stream.t  view on Meta::CPAN

	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" );
	}
	is( $re->window_count,       $m->window_count,       'window survived the round trip' );
	is( $re->seen,               $m->seen,               'seen survived the round trip' );
	is( $re->decision_threshold, $m->decision_threshold, 'threshold survived the round trip' );
	is_deeply( $re->feature_names, [ 'x', 'y' ], 'feature names survived the round trip' );

	# The stream can resume: learning after a reload keeps the window
	# bookkeeping intact.
	$re->learn( cluster( 100, 0 ) );
	is( $re->window_count, 256, 'window still capped after resuming' );
	is( $re->seen,         500, 'seen kept counting after resuming' );
	for my $tree ( @{ $re->{trees} } ) {
		is( $tree->{count}, 256, 'per-tree counts consistent after resuming' );
	}

	# Parent-class load dispatches on the format tag.
	my $via_parent = Algorithm::Classifier::IsolationForest->load($path);
	isa_ok( $via_parent, $class, 'parent load() returns an online model' );

	# And the online class refuses a batch model.
	srand(12);
	my $batch = Algorithm::Classifier::IsolationForest->new( n_trees => 5, seed => 1 );
	$batch->fit( cluster( 64, 0 ) );
	ok( !eval { $class->from_json( $batch->to_json );    1 }, 'online from_json rejects a batch model' );
	ok( !eval { $class->from_json('{"format":"bogus"}'); 1 }, 'online from_json rejects unknown formats' );
}; ## end 'persistence round trip' => sub

done_testing;



( run in 1.167 second using v1.01-cache-2.11-cpan-6aa56a78535 )