Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

t/40-anomaly-detection.t  view on Meta::CPAN

#!perl
use strict;
use warnings;
use Test::More;
use List::Util qw(sum min max);

use Algorithm::Classifier::IsolationForest;

my $CLASS = 'Algorithm::Classifier::IsolationForest';

# Run against the pure-Perl backend always, and against C when it compiled.
# A missing C backend skips that arm rather than failing.
my @BACKENDS = ( [ 'pure-perl' => 0 ] );
push @BACKENDS, [ 'C' => 1 ]
	if $Algorithm::Classifier::IsolationForest::HAS_C;

# Build a deterministic dataset: a dense, well-populated cluster of "normal"
# points inside [-1, 1]^2, plus a handful of outliers placed far outside it.
# The data contains no randomness, so the only stochastic element is the
# forest's own partitioning, which we seed for reproducibility. The clusters
# are separated widely enough that the qualitative result (outliers score
# higher) holds for any reasonable RNG, not just one platform's.
my ( @inliers, @outliers );
for my $i ( -7 .. 7 ) {
	for my $j ( -7 .. 7 ) {
		push @inliers, [ $i / 7, $j / 7 ];
	}
}
@outliers = ( [ 6, 6 ], [ -6, 6 ], [ 6, -6 ], [ -6, -6 ], [ 0, 8 ], [ 8, 0 ], [ -8, 0 ], [ 0, -8 ] );

sub mean { @_ ? sum(@_) / @_ : 0 }

for my $be (@BACKENDS) {
	my ( $be_name, $USE_C ) = @$be;

	subtest "[$be_name] axis-parallel Isolation Forest separates outliers from inliers" => sub {
		my $f = $CLASS->new(
			n_trees     => 100,
			sample_size => 256,
			seed        => 42,
			use_c       => $USE_C
		);
		$f->fit( [ @inliers, @outliers ] );

		my $in_scores  = $f->score_samples( \@inliers );
		my $out_scores = $f->score_samples( \@outliers );

		my $mean_in  = mean(@$in_scores);
		my $mean_out = mean(@$out_scores);

		cmp_ok( $mean_out,         '>', $mean_in + 0.2,   'outliers score clearly higher on average than inliers' );
		cmp_ok( max(@$in_scores),  '<', 0.55,             'inliers stay well below the 0.5 anomaly line' );
		cmp_ok( min(@$out_scores), '>', 0.6,              'outliers sit well above the 0.5 anomaly line' );
		cmp_ok( min(@$out_scores), '>', max(@$in_scores), 'every outlier scores higher than every inlier' );

		# predict() with the default 0.5 cutoff should recover the labelling.
		is(
			sum( @{ $f->predict( \@outliers ) } ),
			scalar @outliers,
			'predict() flags all outliers at the default cutoff'
		);
		cmp_ok(
			sum( @{ $f->predict( \@inliers ) } ),
			'<',
			0.05 * @inliers,
			'predict() flags very few inliers (< 5%)'
		);
	}; ## end "[$be_name] axis-parallel Isolation Forest separates outliers from inliers" => sub

	subtest "[$be_name] Extended Isolation Forest also separates the outliers" => sub {
		my $f = $CLASS->new(
			n_trees     => 100,
			sample_size => 256,
			mode        => 'extended',
			seed        => 7,
			use_c       => $USE_C,
		);
		$f->fit( [ @inliers, @outliers ] );

		my $mean_in  = mean( @{ $f->score_samples( \@inliers ) } );
		my $mean_out = mean( @{ $f->score_samples( \@outliers ) } );
		cmp_ok( $mean_out, '>', $mean_in + 0.15, 'extended-mode outliers also score clearly higher than inliers' );
	}; ## end "[$be_name] Extended Isolation Forest also separates the outliers" => sub

	subtest "[$be_name] seeding makes training reproducible" => sub {
		my @train = ( @inliers, @outliers );

		my $a = $CLASS->new(
			n_trees     => 40,
			sample_size => 128,
			seed        => 99,
			use_c       => $USE_C
		);
		my $b = $CLASS->new(
			n_trees     => 40,
			sample_size => 128,
			seed        => 99,
			use_c       => $USE_C
		);
		$a->fit( \@train );
		$b->fit( \@train );

		my $sa = $a->score_samples( \@train );
		my $sb = $b->score_samples( \@train );

		my $diffs = grep { $sa->[$_] != $sb->[$_] } 0 .. $#$sa;
		is( $diffs, 0, 'two forests built with the same seed produce identical scores' );
	}; ## end "[$be_name] seeding makes training reproducible" => sub
} ## end for my $be (@BACKENDS)

done_testing;



( run in 0.643 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )