Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

t/80-sklearn-comparison.t  view on Meta::CPAN

	};
} ## 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
# is the opposite direction from this module (higher = more anomalous),
# so we negate sklearn scores before computing rank correlation.
# -----------------------------------------------------------------------
my $py_script = <<'END_PY';
import sys, json
import numpy as np
from sklearn.ensemble import IsolationForest

results = {}
for spec in sys.argv[1:]:
    csv_path, label = spec.split('|', 1)
    rows = []
    with open(csv_path) as f:
        for line in f:
            line = line.strip()
            if line:
                rows.append([float(x) for x in line.split(',')])
    X = np.array(rows)
    psi = min(256, len(X))
    clf = IsolationForest(
        n_estimators=100,
        max_samples=psi,
        contamination='auto',
        random_state=42,
    )
    clf.fit(X)
    results[label] = clf.score_samples(X).tolist()

print(json.dumps(results))
END_PY

my ( $py_fh, $py_path ) = tempfile( SUFFIX => '.py', UNLINK => 1 );
print $py_fh $py_script;
close $py_fh;

# Write one CSV per dataset, then build the argv spec.
my @specs;
for my $ds (@datasets) {
	my ( $csv_fh, $csv_path ) = tempfile( SUFFIX => '.csv', UNLINK => 1 );
	for my $row ( @{ $ds->{data} } ) {
		print $csv_fh join( ',', @$row ) . "\n";
	}
	close $csv_fh;
	push @specs, qq("$csv_path|$ds->{label}");
}

my $raw = `$python_bin "$py_path" @{[ join ' ', @specs ]} 2>/dev/null`;
my $py  = eval { JSON::PP->new->decode($raw) };

unless ( defined $py && ref $py eq 'HASH' ) {
	plan skip_all => 'Python/sklearn script returned unusable output; skipping';
}

# -----------------------------------------------------------------------
# Per-dataset test battery
# -----------------------------------------------------------------------
sub run_dataset_tests {



( run in 0.858 second using v1.01-cache-2.11-cpan-995e09ba956 )