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;
cmp_ok( $inlier_flags, '<=', 15, 'default cutoff flags a minority of the inliers' );
my $labels06 = $f->predict( \@data, 0.6 );
ok( ( grep { $labels06->[$_] == 1 } @outlier_idx ) == @outlier_idx,
'outliers still flagged at threshold 0.6' );
my $inlier_flags06 = grep { $labels06->[$_] } 0 .. 59;
cmp_ok( $inlier_flags06, '<=', 3, 'few or no inliers flagged at threshold 0.6' );
# The paired and split shapes agree with the flat ones.
my $pairs = $f->score_predict_samples( \@data );
my ( $s2, $l2 ) = $f->score_predict_split( \@data );
my $pair_bad = grep {
abs( $pairs->[$_][0] - $scores->[$_] ) > 1e-12
|| $pairs->[$_][1] != $labels->[$_]
|| abs( $s2->[$_] - $scores->[$_] ) > 1e-12
|| $l2->[$_] != $labels->[$_]
} 0 .. $#$labels;
is( $pair_bad, 0, 'score_predict_samples and score_predict_split agree with score_samples/predict' );
}; ## end "majority scoring and prediction ($mode mode)" => sub
} ## end for my $mode (qw(axis extended))
SKIP: {
skip 'C vs Perl comparison needs Inline::C', 1 unless $HAS_C;
subtest 'C-backed and pure-Perl majority voting agree' => sub {
for my $mode (qw(axis extended)) {
my %args = (
n_trees => 40,
sample_size => 32,
seed => 19,
mode => $mode,
voting => 'majority',
);
my $fc = $CLASS->new( %args, use_c => 1 )->fit( \@data );
my $fp = $CLASS->new( %args, use_c => 0 )->fit( \@data );
# Identical seed => bit-identical trees, and votes are integer
# counts, so the fractions must match exactly.
my $sc = $fc->score_samples( \@data );
my $sp = $fp->score_samples( \@data );
my $diff = grep { $sc->[$_] != $sp->[$_] } 0 .. $#$sc;
is( $diff, 0, "$mode: vote fractions identical across backends" );
my $lc = $fc->predict( \@data );
my $lp = $fp->predict( \@data );
$diff = grep { $lc->[$_] != $lp->[$_] } 0 .. $#$lc;
is( $diff, 0, "$mode: labels identical across backends" );
my ( $s1, $l1 ) = $fc->score_predict_split( \@data );
my ( $s2, $l2 ) = $fp->score_predict_split( \@data );
$diff = grep { $s1->[$_] != $s2->[$_] || $l1->[$_] != $l2->[$_] } 0 .. $#$s1;
is( $diff, 0, "$mode: score_predict_split identical across backends" );
} ## end for my $mode (qw(axis extended))
}; ## end 'C-backed and pure-Perl majority voting agree' => sub
} ## end SKIP:
subtest 'persistence round trip preserves voting' => sub {
my $f = $CLASS->new(
n_trees => 30,
sample_size => 32,
seed => 23,
voting => 'majority',
)->fit( \@data );
my $json = $f->to_json;
like( $json, qr/"voting"\s*:\s*"majority"/, 'to_json records the voting mode' );
my $r = $CLASS->from_json($json);
is( $r->{voting}, 'majority', 'from_json restores the voting mode' );
my $s0 = $f->score_samples( \@data );
my $s1 = $r->score_samples( \@data );
my $diff = grep { abs( $s0->[$_] - $s1->[$_] ) > 1e-12 } 0 .. $#$s0;
is( $diff, 0, 'reloaded model votes identically' );
my $l0 = $f->predict( \@data );
my $l1 = $r->predict( \@data );
$diff = grep { $l0->[$_] != $l1->[$_] } 0 .. $#$l0;
is( $diff, 0, 'reloaded model predicts identically' );
# Models saved before the knob existed have no voting key at all;
# they must come back as plain mean-aggregation models.
require JSON::PP;
my $payload = JSON::PP->new->decode($json);
delete $payload->{params}{voting};
my $old = $CLASS->from_json( JSON::PP->new->encode($payload) );
is( $old->{voting}, 'mean', 'models without a voting key load as mean' );
}; ## end 'persistence round trip preserves voting' => sub
subtest 'contamination learns a per-tree cutoff' => sub {
my $f = $CLASS->new(
n_trees => 100,
sample_size => 64,
seed => 42,
voting => 'majority',
contamination => 0.05,
)->fit( \@data );
ok( defined $f->decision_threshold, 'a decision threshold was learned' );
cmp_ok( $f->decision_threshold, '>', 0, 'threshold is positive' );
cmp_ok( $f->decision_threshold, '<', 1, 'threshold is below 1' );
my $flags = $f->predict( \@data );
my $flagged = grep { $_ } @$flags;
my $k = int( 0.05 * scalar(@data) + 0.5 );
# Majority pivots are quantized, so ties at the boundary can shift the
# attainable count off k -- but it must stay in the neighbourhood and
# the planted outliers must be inside it.
cmp_ok( $flagged, '>=', 1, 'at least one training point is flagged' );
cmp_ok( $flagged, '<=', 3 * $k, 'flagged count stays near the requested fraction' );
ok( ( grep { $flags->[$_] == 1 } @outlier_idx ) == @outlier_idx, 'the planted outliers are flagged' );
}; ## end 'contamination learns a per-tree cutoff' => sub
subtest 'higher per-tree threshold never flags more points' => sub {
my $f = $CLASS->new(
n_trees => 50,
sample_size => 32,
seed => 31,
voting => 'majority',
)->fit( \@data );
my $low = grep { $_ } @{ $f->predict( \@data, 0.45 ) };
my $mid = grep { $_ } @{ $f->predict( \@data, 0.55 ) };
my $high = grep { $_ } @{ $f->predict( \@data, 0.70 ) };
cmp_ok( $low, '>=', $mid, 'flag count non-increasing from 0.45 to 0.55' );
cmp_ok( $mid, '>=', $high, 'flag count non-increasing from 0.55 to 0.70' );
}; ## end 'higher per-tree threshold never flags more points' => sub
subtest 'set_voting switches an existing model' => sub {
# A model switched to a mode reproduces one fit directly in that mode:
# the trees are voting-independent, and set_voting relearns the
# contamination threshold against the same training data.
my %args = (
n_trees => 100,
sample_size => 64,
seed => 42,
contamination => 0.05,
);
my $ref = $CLASS->new( %args, voting => 'majority' )->fit( \@data );
my $sw = $CLASS->new( %args, voting => 'mean' )->fit( \@data );
my $mean_thr = $sw->decision_threshold;
is( $sw->set_voting( 'majority', \@data ), $sw, 'set_voting returns $self for chaining' );
is( $sw->{voting}, 'majority', 'voting mode updated' );
isnt(
sprintf( '%.12g', $sw->decision_threshold ),
sprintf( '%.12g', $mean_thr ),
'threshold was recalibrated, not left at the mean value'
);
cmp_ok( abs( $sw->decision_threshold - $ref->decision_threshold ),
'<', 1e-12, 'recalibrated threshold matches a model fit directly as majority' );
my $lr = $ref->predict( \@data );
my $ls = $sw->predict( \@data );
my $mismatch = grep { $lr->[$_] != $ls->[$_] } 0 .. $#$lr;
is( $mismatch, 0, 'switched model predicts identically to the reference' );
# Switching back to mean relearns the mean-mode cutoff.
$sw->set_voting( 'mean', \@data );
cmp_ok( abs( $sw->decision_threshold - $mean_thr ),
'<', 1e-12, 'switching back to mean restores the mean-mode threshold' );
# A no-op switch needs no data and returns self.
is( $sw->set_voting('mean'), $sw, 'switching to the current mode is a no-op returning $self' );
# A contamination-fitted model refuses to switch without the data.
my $need = $CLASS->new( %args, voting => 'majority' )->fit( \@data );
eval { $need->set_voting('mean') };
like( $@, qr/requires the original training data/, 'contamination model croaks without data' );
is( $need->{voting}, 'majority', 'mode unchanged after the croak' );
# A model with no contamination switches freely, no data required.
my $free = $CLASS->new( n_trees => 50, sample_size => 32, seed => 7, voting => 'mean' )->fit( \@data );
$free->set_voting('majority');
is( $free->{voting}, 'majority', 'non-contamination model switches without data' );
is( $free->decision_threshold, undef, 'no threshold to recalibrate when contamination was never set' );
# Invalid values are rejected.
eval { $free->set_voting('plurality') };
like( $@, qr/must be 'mean' or 'majority'/, 'invalid voting value croaks' );
# Switching before fit just records the mode for the eventual fit.
my $pre = $CLASS->new( n_trees => 20, sample_size => 16, seed => 5 );
$pre->set_voting('majority');
is( $pre->{voting}, 'majority', 'set_voting before fit records the mode' );
( run in 0.609 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )