Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

t/37-majority-voting.t  view on Meta::CPAN

#!perl
# 37-majority-voting.t
#
# Exercises voting => 'majority' (Majority Voting Isolation Forest,
# MVIForest -- Chabchoub, Togbe, Boly & Chiky 2022):
#
#   * constructor validation of the voting knob
#   * score_samples returns the anomaly vote fraction: [0, 1], discrete
#     in steps of 1/n_trees, higher for obvious outliers
#   * predict labels are the majority of the per-tree votes, consistent
#     with the vote fractions, in both axis and extended mode
#   * score_predict_samples / score_predict_split agree with
#     score_samples + predict
#   * C-backed and pure-Perl paths produce identical votes and labels
#   * persistence: voting survives a to_json/from_json round trip, and
#     models saved before the knob existed load as 'mean'
#   * contamination learns a per-tree cutoff that flags roughly the
#     requested fraction of the training set (majority pivots are
#     quantized, so ties can shift the count to the nearest gap)
#   * a higher per-tree threshold never flags more points
#   * set_voting switches an existing model and recalibrates the
#     contamination threshold for the mode it was set to
#   * the tagged single-row helpers work under majority voting
#   * the CLI accepts --voting and stores it on the model

use strict;
use warnings;
use Test::More;
use File::Spec;

use Algorithm::Classifier::IsolationForest;

my $CLASS = 'Algorithm::Classifier::IsolationForest';
my $HAS_C = $Algorithm::Classifier::IsolationForest::HAS_C ? 1 : 0;

# Uniform cluster plus unmistakable outliers, as in 02-accel-selection.t.
srand(11);
my @data;
push @data, [ rand(), rand(), rand() ] for 1 .. 60;
push @data, [ 12, 12, 12 ], [ -11, -11, -11 ], [ 10, -10, 9 ];
my @outlier_idx = ( 60, 61, 62 );

subtest 'constructor validation' => sub {
	my $f = $CLASS->new( n_trees => 10, sample_size => 16 );
	is( $f->{voting}, 'mean', 'voting defaults to mean' );

	$f = $CLASS->new( n_trees => 10, sample_size => 16, voting => 'majority' );
	is( $f->{voting}, 'majority', 'voting => majority accepted' );

	eval { $CLASS->new( voting => 'plurality' ) };
	like( $@, qr/voting must be 'mean' or 'majority'/, 'invalid voting croaks' );
}; ## end 'constructor validation' => sub

for my $mode (qw(axis extended)) {
	subtest "majority scoring and prediction ($mode mode)" => sub {
		my $t = 50;
		my $f = $CLASS->new(
			n_trees     => $t,
			sample_size => 32,
			seed        => 7,
			mode        => $mode,
			voting      => 'majority',
		)->fit( \@data );

		my $scores = $f->score_samples( \@data );
		is( scalar @$scores, scalar @data, 'one score per sample' );

		my $bad = grep { !defined $_ || $_ < 0 || $_ > 1 } @$scores;
		is( $bad, 0, 'every vote fraction is in [0, 1]' );

		# Vote fractions are counts over $t trees; votes/t scaled back up
		# must land on an integer.
		my $offgrid = grep {
			my $v = $_ * $t;
			abs( $v - int( $v + 0.5 ) ) > 1e-9
		} @$scores;
		is( $offgrid, 0, "every score is a multiple of 1/$t" );

		my $labels = $f->predict( \@data );
		is( scalar @$labels, scalar @data, 'one label per sample' );

		# Labels must be the majority relation applied to the fractions:
		# anomalous iff votes >= int(t/2) + 1, i.e. fraction > 0.5.
		my $maj        = int( $t / 2 ) + 1;
		my $mismatches = grep {
			my $votes = int( $scores->[$_] * $t + 0.5 );
			( $votes >= $maj ? 1 : 0 ) != $labels->[$_]
		} 0 .. $#$labels;
		is( $mismatches, 0, 'labels equal the majority of the votes' );

		ok( ( grep { $labels->[$_] == 1 } @outlier_idx ) == @outlier_idx, 'all planted outliers are flagged' );

		# 0.5 is a weak per-tree bar (the paper recommends 0.6 as the
		# decision threshold), so allow some inlier false alarms at the
		# default and check the sharper separation at 0.6.
		my $inlier_flags = grep { $labels->[$_] } 0 .. 59;



( run in 0.529 second using v1.01-cache-2.11-cpan-e623d60df62 )