Algorithm-Classifier-IsolationForest
view release on metacpan or search on metacpan
t/80-sklearn-comparison.t view on Meta::CPAN
#!perl
# 80-sklearn-comparison.t
#
# Cross-language validation: trains both this module and Python scikit-learn's
# IsolationForest on the same dataset and verifies that the two implementations
# agree on anomaly ordering. The whole file is skipped when Python or
# scikit-learn is not installed.
#
# The same battery of checks is run against multiple datasets so we exercise
# more than the 2-feature case:
#
# * "2d_grid" -- 225 inliers on a regular grid in [-1,1]^2 + 8 outliers
# * "5d_gaussian" -- 200 Gaussian inliers + 8 corner-style outliers (5 dims)
# * "10d_gaussian" -- 200 Gaussian inliers + 8 corner-style outliers (10 dims)
#
# Agreement is verified by three complementary checks per dataset:
# 1. Both models clearly separate the obvious outliers from the inliers
# (score direction test -- Perl: higher = anomalous; sklearn: lower).
# 2. Both models rank the obvious outliers as the top-N anomalies.
# 3. The Spearman rank correlation between the two score vectors is >= 0.85.
#
# Because the models use different RNG implementations they cannot produce
# identical floating-point scores, but any faithful Isolation Forest
# implementation produces highly correlated anomaly rankings on
# well-separated data.
use strict;
use warnings;
use Test::More;
use List::Util qw(sum min max);
use File::Temp qw(tempfile);
use JSON::PP ();
use Algorithm::Classifier::IsolationForest;
my $CLASS = 'Algorithm::Classifier::IsolationForest';
# Compare each backend against sklearn: pure-Perl always, C when it compiled.
# A missing C backend skips that arm rather than failing. sklearn itself is
# run only once (it is unaffected by which Perl backend scores).
my @BACKENDS = ( [ 'pure-perl' => 0 ] );
push @BACKENDS, [ 'C' => 1 ]
if $Algorithm::Classifier::IsolationForest::HAS_C;
use constant PI => 3.14159265358979;
# -----------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------
sub mean { @_ ? sum(@_) / @_ : 0 }
# Assign 1-based ranks; lower value gets lower rank.
sub _assign_ranks {
my @v = @_;
my @idx = sort { $v[$a] <=> $v[$b] } 0 .. $#v;
my @r;
$r[ $idx[$_] ] = $_ + 1 for 0 .. $#idx;
return @r;
}
# Pearson correlation of two rank vectors (= Spearman rho of the originals).
sub spearman_rho {
my ( $xs, $ys ) = @_;
my @rx = _assign_ranks(@$xs);
my @ry = _assign_ranks(@$ys);
my $n = scalar @rx;
my ( $sa, $sb, $saa, $sbb, $sab ) = (0) x 5;
for my $i ( 0 .. $n - 1 ) {
$sa += $rx[$i];
$sb += $ry[$i];
$saa += $rx[$i]**2;
$sbb += $ry[$i]**2;
$sab += $rx[$i] * $ry[$i];
}
my ( $ma, $mb ) = ( $sa / $n, $sb / $n );
my $cov = $sab / $n - $ma * $mb;
my $da = sqrt( $saa / $n - $ma**2 );
my $db = sqrt( $sbb / $n - $mb**2 );
return ( $da > 0 && $db > 0 ) ? $cov / ( $da * $db ) : 0;
} ## end sub spearman_rho
sub gaussian {
my ( $mu, $sigma ) = @_;
return $mu + $sigma * sqrt( -2 * log( rand() || 1e-12 ) ) * cos( 2 * PI * rand() );
}
# -----------------------------------------------------------------------
# Datasets
#
# Each dataset hashref has:
# label -- short identifier (used as Python output key + subtest name)
# n_feat -- feature count
# inliers -- arrayref of inlier rows
# outliers -- arrayref of outlier rows
# data -- inliers followed by outliers (order matters: tests index by
# position to pick out outliers in the combined score vector)
# n_in -- scalar @inliers
# n_out -- scalar @outliers
# -----------------------------------------------------------------------
# 2D regular grid + corner/axis outliers (the original dataset).
sub make_2d_grid_dataset {
my @inliers;
for my $i ( -7 .. 7 ) {
for my $j ( -7 .. 7 ) {
push @inliers, [ $i / 7.0, $j / 7.0 ];
}
}
my @outliers = ( [ 6, 6 ], [ -6, 6 ], [ 6, -6 ], [ -6, -6 ], [ 0, 8 ], [ 8, 0 ], [ -8, 0 ], [ 0, -8 ] );
return {
label => '2d_grid',
n_feat => 2,
inliers => \@inliers,
outliers => \@outliers,
data => [ @inliers, @outliers ],
n_in => scalar @inliers,
n_out => scalar @outliers,
};
} ## end sub make_2d_grid_dataset
# N-D Gaussian inliers + corner-style outliers far from origin in every axis.
# Deterministic via a fixed srand seed derived from the dimension.
sub make_nd_gaussian_dataset {
my ($nf) = @_;
srand( 20260629 + $nf );
my @inliers;
push @inliers, [ map { gaussian( 0, 0.3 ) } 1 .. $nf ] for 1 .. 200;
# Outliers: each coordinate at magnitude 5..8 with random sign so the
# point sits at a "corner" of the bounding box, well outside the inlier
# cluster along *every* axis. Eight of them, to match the 2D dataset.
my @outliers;
for ( 1 .. 8 ) {
my @row;
for ( 1 .. $nf ) {
my $mag = 5 + rand() * 3;
my $sign = rand() > 0.5 ? 1 : -1;
push @row, $mag * $sign;
}
push @outliers, \@row;
}
return {
label => "${nf}d_gaussian",
n_feat => $nf,
inliers => \@inliers,
outliers => \@outliers,
data => [ @inliers, @outliers ],
n_in => scalar @inliers,
n_out => scalar @outliers,
};
} ## end sub make_nd_gaussian_dataset
my @datasets = ( make_2d_grid_dataset(), make_nd_gaussian_dataset(5), make_nd_gaussian_dataset(10), );
# -----------------------------------------------------------------------
# Locate Python + scikit-learn; skip the whole file if unavailable
# -----------------------------------------------------------------------
my $python_bin;
for my $cmd (qw(python3 python)) {
my $probe = `$cmd -c "import sklearn; print('ok')" 2>/dev/null`;
if ( defined $probe && $probe =~ /\bok\b/ ) {
$python_bin = $cmd;
last;
}
}
unless ( defined $python_bin ) {
plan skip_all => 'Python with scikit-learn is not installed; skipping cross-language comparison';
}
# -----------------------------------------------------------------------
# Python helper: train sklearn IsolationForest per dataset and emit JSON.
#
# Receives "csv_path|label" pairs on argv and returns a JSON object keyed
# by label with the corresponding score_samples() output. Batching all
# datasets into a single subprocess avoids paying Python + sklearn import
# cost more than once.
#
# sklearn score_samples convention: lower score = more anomalous. That
( run in 1.597 second using v1.01-cache-2.11-cpan-9581c071862 )