Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

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

	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' );
	$pre->fit( \@data );
	is( $pre->{voting}, 'majority', 'mode survives the subsequent fit' );
}; ## end 'set_voting switches an existing model' => sub

subtest 'tagged single-row helpers work under majority voting' => sub {
	my $f = $CLASS->new(
		n_trees       => 30,
		sample_size   => 32,
		seed          => 13,
		voting        => 'majority',
		feature_names => [qw(x y z)],
	)->fit( \@data );

	my $out   = { x => 12,  y => 12,  z => 12 };
	my $in    = { x => 0.5, y => 0.5, z => 0.5 };
	my $score = $f->score_sample_tagged($out);
	cmp_ok( $score, '>', 0.5, 'tagged outlier row has a majority vote fraction' );
	is( $f->predict_tagged($out), 1, 'tagged outlier row predicted anomalous' );
	is( $f->predict_tagged($in),  0, 'tagged inlier row predicted normal' );

	my $pair = $f->score_predict_sample_tagged($out);
	is( abs( $pair->[0] - $score ) < 1e-12 ? 1 : 0, 1, 'tagged pair score matches score_sample_tagged' );
	is( $pair->[1],                                 1, 'tagged pair label matches predict_tagged' );
}; ## end 'tagged single-row helpers work under majority voting' => sub

# ------------------------------------------------------------------------
# CLI: fit --voting must validate the value and store it on the model.
# ------------------------------------------------------------------------
SKIP: {
	my $bin = File::Spec->rel2abs('bin/iforest');
	skip "bin/iforest not found", 1 unless -x $bin;

	subtest 'CLI fit --voting' => sub {
		require File::Temp;
		my ( $fh, $csv ) = File::Temp::tempfile( SUFFIX => '.csv', UNLINK => 1 );
		for my $row (@data) {
			print $fh join( ',', @$row ) . "\n";
		}
		close $fh;

		my $out = `$^X -Ilib $bin fit -i $csv -p --voting majority -s 5 2>&1`;
		is( $?, 0, 'fit --voting majority exits 0' );
		like( $out, qr/"voting"\s*:\s*"majority"/, 'printed model records voting => majority' );



( run in 2.578 seconds using v1.01-cache-2.11-cpan-9581c071862 )