Algorithm-Classifier-IsolationForest
view release on metacpan or search on metacpan
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
if defined $self->{$doc} && ref $self->{$doc};
}
_validate_feature_descriptions( $self->{feature_names}, $self->{feature_descriptions} )
if defined $self->{feature_descriptions};
# Optional Algorithm::ToNumberMunger integration: a declarative spec
# hash compiled into a plan that turns raw tagged values into numbers.
# Compiled eagerly so every spec error surfaces here rather than at
# first scoring; the module itself is only required when a spec is
# actually given, keeping it an optional dependency.
if ( defined $args{mungers} ) {
croak "mungers must be a hashref of 'tag => munger spec'"
unless ref $args{mungers} eq 'HASH';
croak "mungers requires feature_names (the munger plan compiles against them)"
unless ref $self->{feature_names} eq 'ARRAY' && @{ $self->{feature_names} };
$self->{mungers} = $args{mungers};
$self->{_munger_plan} = _compile_mungers( $self->{feature_names}, $self->{mungers} );
$self->{munger_module_version} = $Algorithm::ToNumberMunger::VERSION;
}
croak "n_trees must be >= 1" unless $self->{n_trees} >= 1;
croak "sample_size must be >= 1" unless $self->{sample_size} >= 1;
croak "extension_level must be >= 0"
if defined $self->{extension_level} && $self->{extension_level} < 0;
croak "contamination must be a number in (0, 0.5]"
if defined $self->{contamination}
&& !( $self->{contamination} > 0 && $self->{contamination} <= 0.5 );
croak "parallel_fit must be a positive integer"
if defined $self->{parallel_fit}
&& ( $self->{parallel_fit} !~ /^\d+$/ || $self->{parallel_fit} < 1 );
return bless $self, $class;
} ## end sub new
=head2 decision_threshold
The score cutoff C<predict> uses by default; undef unless C<contamination> was
set.
=cut
sub decision_threshold { return $_[0]->{threshold} }
=head2 set_voting
Switches the scoring-time aggregation between C<'mean'> and C<'majority'> on an
existing model and returns C<$self> (so it chains). The forest itself is
identical in both modes -- only the way per-tree results are combined changes
-- so this never rebuilds a single tree.
$iforest->set_voting('majority');
$iforest->set_voting('mean', \@training_data);
The one thing that does not carry over is a C<contamination>-learned
L</decision_threshold>. That cutoff is a quantile of whichever per-point
quantity the mode thresholds against -- the averaged anomaly score under
C<'mean'>, the per-tree majority pivot under C<'majority'> -- and those live in
different spaces, so a threshold learned in one mode flags the wrong fraction
in the other. When the model was fitted with C<contamination>, C<set_voting>
therefore relearns the threshold for the target mode, which requires the
original training data to be passed as the second argument (the model does not
retain it). Switching a model that had no C<contamination> needs no data:
C<predict> falls back to C<0.5>, which is meaningful in both modes.
Passing the current mode is a no-op (returns immediately, no data needed).
Calling this before L</fit> just records the mode for the eventual fit.
=cut
sub set_voting {
my ( $self, $voting, $data ) = @_;
croak "set_voting: voting must be 'mean' or 'majority'"
unless defined $voting && $voting =~ /\A(?:mean|majority)\z/;
return $self if $self->{voting} eq $voting;
# A learned threshold only exists once a contamination-fitted model has
# been fit(); that value is mode-specific and must be relearned against
# the same training set (see _learn_contamination_threshold). Everything
# else -- pre-fit models, and fitted models without contamination -- just
# flips the knob; predict()'s 0.5 fallback is valid in either mode.
my $fitted = ref $self->{trees} eq 'ARRAY' && @{ $self->{trees} };
my $recalibrate = $fitted && defined $self->{contamination};
if ($recalibrate) {
croak "set_voting: switching a contamination-fitted model requires "
. "the original training data as the second argument to "
. "recalibrate the decision threshold"
unless ref $data eq 'ARRAY' && @$data;
}
$self->{voting} = $voting;
$self->_learn_contamination_threshold($data) if $recalibrate;
return $self;
} ## end sub set_voting
=head2 feature_names
Returns the arrayref of feature name strings stored with the model, or undef
if none were provided at fit time.
my $names = $iforest->feature_names;
=cut
sub feature_names { return $_[0]->{feature_names} }
=head2 schema_version
Returns the user-owned schema version string stored with the model
(usually via a prototype -- see L</PROTOTYPES>), or undef if none was
recorded.
my $sv = $iforest->schema_version;
=cut
sub schema_version { return $_[0]->{schema_version} }
=head2 schema_description
Returns the free-text description of the variable schema stored with the
model, or undef if none was recorded.
=cut
sub schema_description { return $_[0]->{schema_description} }
=head2 feature_descriptions
Returns the hashref of per-feature description strings stored with the
model, or undef if none were recorded. Keys are feature names; coverage
may be partial.
=cut
sub feature_descriptions { return $_[0]->{feature_descriptions} }
=head2 fit
Trains the model on the specified data.
The data taken is an array of arrays. Each sub-array is one sample and must
contain one or more numeric features. All samples must have the same number
of features. There is no upper limit on dimensionality.
@training_data = (
[ 3, 5 ],
[ 2.3, 1 ],
[ 5, 9 ],
...
);
# Three-feature example
@training_data = (
[ 1.0, 2.0, 3.0 ],
[ 1.1, 1.9, 3.1 ],
...
);
Below shows an example of building a gaussian cluster and using that for training.
# so it is reproducible
srand(7);
# build a gaussian cluster and add a handful of outliers...
use constant PI => 3.14159265358979;
sub gaussian {
my ($mu, $sigma) = @_;
my $u1 = rand() || 1e-12;
my $u2 = rand();
my $z = sqrt(-2 * log($u1)) * cos(2 * PI * $u2);
return $mu + $sigma * $z;
}
# add some normal items
for (1 .. 500) {
push @data, [ gaussian(0, 1), gaussian(0, 1) ];
push @truth, 0;
}
# add some outliers
for (1 .. 20) {
my $angle = rand() * 2 * PI;
my $radius = 5 + rand() * 3; # distance 5..8 from the origin
push @data, [ $radius * cos($angle), $radius * sin($angle) ];
push @truth, 1;
}
$iforest->fit(\@data);
=cut
sub fit {
my ( $self, $data ) = @_;
croak "fit() expects a non-empty arrayref of samples"
unless ref $data eq 'ARRAY' && @$data;
croak "each sample must be an arrayref of features"
unless ref $data->[0] eq 'ARRAY' && @{ $data->[0] };
my $n_features = scalar @{ $data->[0] };
$self->{n_features} = $n_features;
# Apply the missing-value strategy before any tree is built. Depending
# on the strategy this either croaks (die), returns a dense copy with
# undef cells filled (zero/impute), or passes the data through with
# undef preserved for the split logic to route (nan). Everything below
# trains on $train, never the raw $data.
my $train = $self->_prepare_fit_data($data);
my $n = scalar @$train;
# The sub-sample cannot be larger than the data set itself.
my $psi = min( $self->{sample_size}, $n );
$self->{c_psi} = _c($psi);
$self->{psi_used} = $psi;
# Resolve the extension level against the data's dimensionality.
if ( $self->{mode} eq 'extended' ) {
my $max_ext = $n_features - 1;
my $ext
= defined $self->{extension_level}
? $self->{extension_level}
: $max_ext;
$ext = 0 if $ext < 0;
$ext = $max_ext if $ext > $max_ext;
$self->{extension_level_used} = $ext;
} else {
$self->{extension_level_used} = undef;
}
# Height limit: the average tree height ceil(log2(psi)). Past this depth the
# remaining points are scored using the c(size) adjustment instead.
my $limit
= defined $self->{max_depth}
? $self->{max_depth}
: ceil( log($psi) / log(2) );
$limit = 1 if $limit < 1;
$self->{max_depth_used} = $limit;
srand( $self->{seed} ) if defined $self->{seed};
my $workers = $self->{parallel_fit};
if ( defined $workers
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
# through this one entry point.
if ( $payload->{format} eq 'Algorithm::Classifier::IsolationForest::Online' ) {
require Algorithm::Classifier::IsolationForest::Online;
return Algorithm::Classifier::IsolationForest::Online->from_json($text);
}
croak "not an IsolationForest model"
unless $payload->{format} eq 'Algorithm::Classifier::IsolationForest';
my $p = $payload->{params} || {};
# version 0 used hash-based nodes; version 1+ uses array-based nodes.
# Convert old models on load so the rest of the code only sees arrays.
my $trees = $payload->{trees} || [];
if ( ( $payload->{version} // 0 ) < 1 ) {
$trees = [ map { _hash_node_to_array($_) } @$trees ];
}
my $self = {
n_trees => $p->{n_trees},
sample_size => $p->{sample_size},
max_depth => undef,
seed => undef,
mode => $p->{mode} // 'axis',
extension_level => $p->{extension_level},
extension_level_used => $p->{extension_level},
contamination => $p->{contamination},
threshold => $p->{threshold},
n_features => $p->{n_features},
psi_used => $p->{psi_used},
c_psi => $p->{c_psi},
max_depth_used => $p->{max_depth_used},
# Models saved before missing-value support lack these keys; default
# to 'zero', which reproduces the old undef -> 0 scoring behaviour.
missing => $p->{missing} // 'zero',
impute_with => $p->{impute_with} // 'mean',
missing_fill => $p->{missing_fill},
feature_names => $p->{feature_names},
# The munger plan is recompiled lazily on first tagged use, so a
# munger-bearing model still loads (and scores positional data)
# where Algorithm::ToNumberMunger is not installed.
mungers => $p->{mungers},
munger_module_version => $p->{munger_module_version},
# Opaque schema metadata; absent in models saved before prototype
# support, which just means "none recorded".
schema_version => $p->{schema_version},
schema_description => $p->{schema_description},
feature_descriptions => $p->{feature_descriptions},
# Models saved before majority-voting support lack the key; 'mean'
# reproduces their behaviour exactly.
voting => $p->{voting} // 'mean',
trees => $trees,
_use_c => $HAS_C,
_use_openmp => $HAS_OPENMP,
_use_openmp_fit => 0, # opt-in only; loaded models never re-fit implicitly
};
croak "model contains no trees" unless @{ $self->{trees} };
# Recompute the normalising constant from the (integer, exact) sub-sample
# size rather than trusting the stored float, so a reloaded model's scores
# are bit-for-bit identical to the original's.
$self->{c_psi} = _c( $self->{psi_used} ) if defined $self->{psi_used};
my $model = bless $self, $class;
$model->_rebuild_c_trees() if $self->{_use_c};
return $model;
} ## end sub from_json
=head2 save($path)
Saves the model to the specified path.
$iforest->save($path);
=cut
sub save {
my ( $self, $path ) = @_;
write_file( $path, { 'atomic' => 1 }, $self->to_json );
}
=head2 load($path)
Init the object from the model in the specified file.
my $iforest = Algorithm::Classifier::IsolationForest->load($path);
=cut
sub load {
my ( $class, $path ) = @_;
my $raw_model = read_file($path);
return $class->from_json($raw_model);
}
=head1 PROTOTYPES
A prototype is a small JSON document that describes what a model should
be before any data exists: the variable schema (feature names in column
order, plus their munger specs, per-feature descriptions, and missing
policy), a user-owned C<schema_version> string, a human-readable
C<schema_description>, and optionally the tuning knobs. Creating a
model from one -- L</new_from_prototype($proto, %overrides)> here, or
C<--prototype> on C<iforest fit> / C<iforest stream> -- stamps the
schema metadata into the model JSON, so every downstream consumer
(C<iforest info>, resumed streams, your own tooling) can tell which
revision of the input schema a model was built against.
{
"format": "Algorithm::Classifier::IsolationForest::Prototype",
"version": 1,
"class": "online",
"schema_version": "2026.07.08-1",
"schema_description": "HTTP request stream: method enum, path length, host entropy, raw byte count",
"schema": {
"feature_names": ["method", "path_len", "host_entropy", "bytes"],
"feature_descriptions": {
"method": "HTTP request method, mapped via http_method_enum (-1 = unknown)",
"path_len": "character length of the request path",
"host_entropy": "Shannon entropy of the Host header",
"bytes": "raw response byte count, passed through unmunged"
lib/Algorithm/Classifier/IsolationForest.pm view on Meta::CPAN
my $mode_flag = $self->{mode} eq 'extended' ? 1 : 0;
my $ext_level = $self->{extension_level_used} // 0;
my $trees = [];
build_forest_xs( $x_packed, $n, $nf, $n_trees, $psi, $limit, $mode_flag, $ext_level, $trees );
return $trees;
} ## end sub _build_forest_c
#-------------------------------------------------------------------------------
# OpenMP-parallel fit(): builds $n_trees trees across OpenMP threads (one
# tree per thread) via build_forest_openmp_xs. Unlike _build_forest_c,
# random draws come from a thread-private PRNG seeded per tree index
# rather than Drand01() -- Perl's RNG state can't be shared safely
# across OpenMP threads -- so the resulting trees are NOT bit-identical
# to the use_c (serial) or pure-Perl paths for the same seed, though a
# fixed seed + n_trees still reproduce the same trees regardless of
# OMP_NUM_THREADS. This is why it's gated by the separate, opt-in
# use_openmp_fit knob rather than reusing use_c/use_openmp.
#
# Only called from fit()'s non-forked branch. _fit_trees_parallel's
# workers never call this, even when use_openmp_fit is on: a forked
# child starting its own OpenMP region after the parent process has
# used OpenMP for anything (this includes plain score_samples()) can
# hang -- see the comment above that branch for the fork()+libgomp
# hazard this avoids.
#
# build_forest_openmp_xs hands back three arrayrefs of per-tree packed
# buffers (the same SoA layout _pack_tree produces) instead of Perl tree
# structures -- that's how it avoids any Perl API call inside its
# parallel region. _unpack_forest converts them back into the ordinary
# nested-arrayref tree shape so to_json/from_json/_rebuild_c_trees don't
# need to know this path exists.
#-------------------------------------------------------------------------------
sub _build_forest_openmp {
my ( $self, $data, $psi, $limit, $n_trees ) = @_;
my $n = scalar @$data;
my $nf = $self->{n_features};
my $x_packed = "\0" x ( $n * $nf * 8 );
my ( $mode, $fill ) = $self->_pack_args;
pack_input_xs( $data, $x_packed, $n, $nf, $mode, $fill );
my $mode_flag = $self->{mode} eq 'extended' ? 1 : 0;
my $ext_level = $self->{extension_level_used} // 0;
my ( @nodes, @idx, @val );
build_forest_openmp_xs( $x_packed, $n, $nf, $n_trees, $psi, $limit,
$mode_flag, $ext_level, \@nodes, \@idx, \@val, 1 );
return _unpack_forest( \@nodes, \@idx, \@val );
} ## end sub _build_forest_openmp
# Inverse of _pack_tree's SoA layout: given one tree's packed node
# buffer plus the shared idx/val coefficient buffers, reconstructs the
# ordinary nested-arrayref tree structure _build_tree/_build_node_c
# produce. li/ri fields hold the child's absolute node index, so this
# just follows them recursively from whatever index the caller says the
# root lives at. NOTE: _pack_tree numbers nodes DFS pre-order (root at
# 0), but build_forest_openmp_xs appends nodes post-order (children
# before parent), putting the root LAST -- the caller must pass the
# right root index for the buffer's origin.
sub _unpack_node {
my ( $nodes, $idx, $val, $node_i ) = @_;
my $off = $node_i * 6;
my $type = $nodes->[$off];
if ( $type == 0 ) {
return [ _NODE_LEAF, int( $nodes->[ $off + 1 ] ) ];
} elsif ( $type == 1 ) {
my ( $attr, $split, $li, $ri )
= @{$nodes}[ $off + 1 .. $off + 4 ];
return [
_NODE_AXIS, int($attr), $split,
_unpack_node( $nodes, $idx, $val, int($li) ),
_unpack_node( $nodes, $idx, $val, int($ri) ),
];
} else {
my ( $coff, $num, $li, $ri, $b ) = @{$nodes}[ $off + 1 .. $off + 5 ];
$coff = int($coff);
$num = int($num);
return [
_NODE_OBLIQUE,
[ @{$idx}[ $coff .. $coff + $num - 1 ] ],
[ @{$val}[ $coff .. $coff + $num - 1 ] ],
$b,
_unpack_node( $nodes, $idx, $val, int($li) ),
_unpack_node( $nodes, $idx, $val, int($ri) ),
];
} ## end else [ if ( $type == 0 ) ]
} ## end sub _unpack_node
# Unpacks every tree in the three per-tree packed-buffer arrayrefs
# build_forest_openmp_xs returns into the ordinary nested tree shape.
# The C builder pushes nodes post-order (a node is recorded after both
# of its children), so each tree's root is the LAST node record, not
# index 0 as in _pack_tree's pre-order layout.
sub _unpack_forest {
my ( $nodes_list, $idx_list, $val_list ) = @_;
my @trees;
for my $i ( 0 .. $#$nodes_list ) {
my @nodes = unpack( 'd*', $nodes_list->[$i] );
my @idx = unpack( 'l*', $idx_list->[$i] );
my @val = unpack( 'd*', $val_list->[$i] );
my $root = @nodes / 6 - 1;
push @trees, _unpack_node( \@nodes, \@idx, \@val, $root );
}
return \@trees;
} ## end sub _unpack_forest
#-------------------------------------------------------------------------------
# Packed input wrapper. pack_data() returns one of these so callers can
# score the same dataset many times without re-walking the AV/AV refs on
# every call -- a meaningful win at high feature counts where
# pack_input_xs is a non-trivial slice of total scoring time.
#
# It's a minimal blessed hashref: { packed, n_pts, n_feats }. The C
# scoring functions only need the packed bytes + dimensions.
#-------------------------------------------------------------------------------
sub pack_data {
my ( $self, $data ) = @_;
$self->_check_fitted;
( run in 0.760 second using v1.01-cache-2.11-cpan-9581c071862 )