Algorithm-Classifier-IsolationForest

 view release on metacpan or  search on metacpan

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

    for (t = 0; t < n_trees; t++) {
        HV* tree = _ol_tree_hv(aTHX_ trees, t);
        SV** rsv = hv_fetch(tree, "root", 4, 0);
        AV* node;
        SV** slots;
        int depth = 0;

        if (!rsv || !*rsv || !SvOK(*rsv)) continue;
        node  = (AV*)SvRV(*rsv);
        slots = AvARRAY(node);
        while (SvIV(slots[OL_TYPE]) != 0) {
            int attr     = (int)SvIV(slots[OL_ATTR]);
            double split = SvNV(slots[OL_SPLIT]);
            node  = (AV*)SvRV(slots[(x[attr] < split) ? OL_LEFT : OL_RIGHT]);
            slots = AvARRAY(node);
            depth++;
        }
        sum += (double)depth + _ol_rpl((double)SvIV(slots[OL_COUNT]), eta);
    }
    free(x);
    return sum;
}
__INLINE_C__

	# IF_NO_C=1 skips even attempting to set up the C backend -- useful for
	# forcing the pure-Perl path without touching every constructor call
	# (use_c => 0), e.g. to get a clean timing baseline or to avoid the
	# compile attempt's overhead/noise in a container known to lack a
	# compiler.  Everything below is skipped and $HAS_C stays 0.
	unless ( $ENV{IF_NO_C} ) {

		# Defaults recorded when `perl Makefile.PL` ran.  Makefile.PL generates
		# Algorithm::Classifier::IsolationForest::BuildFlags, capturing the
		# IF_* values active at configure time plus whether a prebuilt object
		# was scheduled for install (see "Compile at install time" in the POD
		# below).  From a plain source checkout the generated file is absent,
		# the hard defaults here apply, and no prebuilt object is looked for.
		my ( $def_opt, $def_arch, $def_no_omp, $prebuilt ) = ( '-O3', '', 0, 0 );
		{
			local $@;
			my $rec = eval {
				require Algorithm::Classifier::IsolationForest::BuildFlags;
				Algorithm::Classifier::IsolationForest::BuildFlags::flags();
			};
			if ( ref $rec eq 'HASH' ) {
				$def_opt    = $rec->{opt}  if defined $rec->{opt};
				$def_arch   = $rec->{arch} if defined $rec->{arch};
				$def_no_omp = $rec->{no_openmp} ? 1 : 0;
				$prebuilt   = $rec->{prebuilt}  ? 1 : 0;
			}
		}

		# -O3 is the usual default: it's safe to enable unconditionally and
		# matters here -- the extended-mode oblique dot product is wrapped in
		# `#pragma omp simd`, but without aggressive optimization the compiler
		# may still emit scalar code.  Use OPTIMIZE (not CCFLAGS) -- CCFLAGS is
		# prepended to the cc line and would be shadowed by Perl's own `-O2 -g`
		# that ExtUtils::MakeMaker appends afterward (last `-O` wins in gcc).
		# IF_OPT overrides the level itself (e.g. IF_OPT=-O2 to work around a
		# miscompile, or to shorten build time while developing); it's
		# validated against a fixed set of GCC/Clang -O flags rather than
		# interpolated as-is, since this string eventually reaches a shell
		# command line via ExtUtils::MakeMaker.
		my $opt = $def_opt;
		if ( defined $ENV{IF_OPT} ) {
			if ( $ENV{IF_OPT} =~ /\A-O[0123sgz]\z/ ) {
				$opt = $ENV{IF_OPT};
			} else {
				warn "Algorithm::Classifier::IsolationForest: ignoring invalid "
					. "IF_OPT value '$ENV{IF_OPT}' (expected one of -O0 -O1 -O2 "
					. "-O3 -Os -Og -Oz); using $opt\n";
			}
		}

		# -march=<value> lets the compiler target specific instruction-set
		# extensions (AVX2 gather + FMA, etc.) for the oblique dot product
		# and the fit-time min/max scan's `#pragma omp simd` loops.
		#
		# IF_ARCH=<value> sets it explicitly (e.g. "x86-64-v3", "skylake",
		# "znver3") -- validated against a conservative identifier charset
		# since, like IF_OPT, it flows into a compiler command line.
		# IF_NATIVE=1 remains as shorthand for IF_ARCH=native and is used
		# when IF_ARCH isn't set. Prefer a specific IF_ARCH value over
		# IF_NATIVE on a machine you don't control exclusively: blanket
		# -march=native pulls in whatever the build host has, including
		# AVX-512 on some Intel CPUs, which is known to trigger clock
		# throttling under sustained heavy use and can make throughput
		# *worse* than a conservative target like x86-64-v3 (AVX2, no
		# AVX-512). Either way, the cached artefact under _Inline/ is then
		# pinned to that instruction set, so leave both unset if the
		# directory is shared across machines with different CPUs.
		my $arch = $def_arch;
		if ( defined $ENV{IF_ARCH} ) {
			if ( $ENV{IF_ARCH} eq '' or $ENV{IF_ARCH} eq 'none' ) {

				# Explicit opt-out: overrides an arch recorded at configure
				# time (there is no other way to request a plain build on
				# an install configured with IF_ARCH).
				$arch = '';
			} elsif ( $ENV{IF_ARCH} =~ /\A[A-Za-z0-9_.+=-]+\z/ ) {
				$arch = $ENV{IF_ARCH};
			} else {
				warn "Algorithm::Classifier::IsolationForest: ignoring invalid " . "IF_ARCH value '$ENV{IF_ARCH}'\n";
			}
		} elsif ( $ENV{IF_NATIVE} ) {
			$arch = 'native';
		}
		# -ffp-contract=off rides along with any -march: once the target
		# has FMA (x86-64-v3, most -march=native hosts), the compiler may
		# otherwise contract a*b+c expressions into fused multiply-adds
		# whose different rounding breaks the documented guarantee that
		# use_c => 1 and use_c => 0 build bit-identical trees (one ulp in a
		# split value cascades into a structurally different tree).  The
		# -march speedup comes from AVX2 vectorization, not contraction,
		# so this costs little (verified against the fit-determinism and
		# scoring-parity tests).
		my $opt_level = $opt;
		$opt_level .= " -march=$arch -ffp-contract=off" if length $arch;

		# IF_NO_OPENMP=1 forces the serial C build: the OpenMP compile attempt
		# is skipped, so the object has no libgomp linkage and never starts an
		# OpenMP runtime in the process.  Distinct from OMP_NUM_THREADS=1,
		# which runs the parallel code on a single thread but still loads
		# libgomp.  An explicit IF_NO_OPENMP=0 re-enables OpenMP over a
		# no-openmp configure-time default.
		my $no_omp
			= defined $ENV{IF_NO_OPENMP}
			? ( $ENV{IF_NO_OPENMP} ? 1 : 0 )
			: $def_no_omp;

		# The prebuilt object is only trusted when the effective flags match
		# what it was compiled with; any difference -- or an explicit
		# IF_RUNTIME_BUILD=1 -- falls through to the classic runtime Inline::C
		# build below, which honours the requested flags via the MD5-keyed
		# _Inline/ cache exactly as before prebuilt support existed.
		# IF_INSTALL_BUILD is the `make` rule driving the install-time compile
		# (see Makefile.PL); it must never short-circuit into loading an
		# older object.
		my $use_prebuilt
			= $prebuilt

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

thereby also fixes what the prebuilt object was compiled with.  At run
time the recorded values serve as the defaults, so a process started
with no C<IF_*> variables set uses the prebuilt object as-is.

Setting C<IF_*> variables at run time keeps working exactly as in
releases without prebuilt support: if the requested flags differ from
the recorded ones, the prebuilt object (compiled with the wrong flags
for the request) is skipped and the module compiles at first load into
C<_Inline/> -- which does need C<Inline::C> and a compiler on that
machine.  Two related knobs exist:

=over 4

=item * C<IF_RUNTIME_BUILD=1> -- ignore the prebuilt object
unconditionally and compile at first load even though the requested
flags match the recorded ones.  Useful when the installed object is
suspect (built on a different CPU than it now runs on, linked against a
libgomp that has since changed) or to A/B a fresh local build against
the shipped one.

=item * C<IF_INSTALL_BUILD=1> -- internal; set by the generated
Makefile rule that performs the install-time compile.  Not meant for
manual use.

=back

If the prebuilt object cannot be loaded for any reason (deleted, built
against a different perl, version mismatch after an upgrade), the
module quietly falls through the same chain as always: runtime
Inline::C build first, pure Perl last.

=head2 Tuning the C build

These environment variables are read once, the first time the module is
loaded, so they must be set before that -- e.g. in the shell before
running a script, not via C<%ENV> inside the script itself.  They are
also read by C<perl Makefile.PL> to pick the flags baked into the
prebuilt object (see above); at run time they override the recorded
configure-time values, at the price of a runtime compile.

=over 4

=item * C<IF_NO_C=1> -- skip attempting to build the C backend entirely.
Equivalent to constructing every instance with C<use_c =E<gt> 0>, but
without needing to touch every call site; useful for a clean pure-Perl
timing baseline, or to avoid the compile attempt's overhead/noise on a
host known to lack a C compiler (the attempt already fails gracefully
without this, so it's a convenience, not a correctness fix).

=item * C<IF_OPT=-O2> (or C<-O0>/C<-O1>/C<-Os>/C<-Og>/C<-Oz>) -- override
the default C<-O3>, e.g. to shorten build time while iterating, or work
around a miscompile on an unusual toolchain. Invalid values are ignored
with a warning rather than passed through, since this string reaches a
compiler command line.

=item * C<IF_ARCH=E<lt>valueE<gt>> -- adds C<-march=E<lt>valueE<gt>> so the
compiler can target specific instruction-set extensions (AVX2 gather +
FMA, etc.) for the extended-mode oblique dot product and the fit-time
min/max scan's C<#pragma omp simd> loops. Accepts values like
C<x86-64-v3>, C<skylake>, or C<znver3> -- whatever your compiler's
C<-march=> accepts. Also validated (a restricted character set, not
passed through as-is) for the same reason as C<IF_OPT>.  The special
value C<none> (or an empty string) opts out of any arch recorded at
configure time, yielding a plain build.  Whenever a C<-march> is in
effect the build also adds C<-ffp-contract=off>: with FMA available
the compiler would otherwise contract C<a*b+c> into fused
multiply-adds whose different rounding breaks the guarantee that
C<use_c =E<gt> 1> and C<use_c =E<gt> 0> build bit-identical trees (the
C<-march> speedup comes from vectorization, not contraction, so this
costs essentially nothing).

=item * C<IF_NATIVE=1> -- shorthand for C<IF_ARCH=native>; ignored if
C<IF_ARCH> is also set. Prefer a specific C<IF_ARCH> value over this on
a machine you don't control exclusively (a shared build host, a
container base image): blanket C<-march=native> pulls in whatever
instruction sets the build host happens to have, including AVX-512 on
some Intel CPUs -- which is known to trigger clock throttling under
sustained heavy use and can make throughput I<worse> than a
conservative target like C<x86-64-v3> (AVX2, no AVX-512). If in doubt,
benchmark both before committing to one.

=item * C<IF_NO_OPENMP=1> -- build (or select) the serial C backend: the
OpenMP compile attempt is skipped entirely, so the resulting object has
no libgomp linkage and never starts an OpenMP runtime inside the
process. This differs from C<OMP_NUM_THREADS=1>, which merely runs the
parallel code on one thread but still loads libgomp. Set at
C<perl Makefile.PL> time it yields a serial prebuilt object; set at run
time against an OpenMP prebuilt install it triggers a runtime serial
build (needing a compiler). An explicit C<IF_NO_OPENMP=0> re-enables
OpenMP over a serial configure-time default.

=back

Whichever of these are used, the cached artefact under C<_Inline/> is
pinned to that build's instruction set -- delete C<_Inline/> (or use a
separate one per host) if the directory is shared across machines with
different CPUs, or a stale binary built for a narrower instruction set
than the current host will simply keep being reused.

=head2 Tuning the OpenMP runtime

These are standard OpenMP environment variables libgomp already reads
at run time (set before running your script, no module-specific
handling needed) -- listed here because they matter most for exactly
the workloads this module has: C<score_all_xs>'s per-point parallel
loop and C<use_openmp_fit>'s per-tree parallel loop.

=over 4

=item * C<OMP_NUM_THREADS=N> -- caps how many threads a parallel region
uses. Useful to leave headroom for other work sharing the machine, or
to pin down C<use_openmp_fit> reproducibility checks (see its docs
above: results don't depend on this, but it's a natural thing to vary
when confirming that).

=item * C<OMP_PROC_BIND=close> / C<OMP_PLACES=cores> -- on multi-socket
or otherwise NUMA machines, pins each thread to a core near where its
data already lives instead of letting the OS scheduler migrate threads
across sockets mid-run. Both C<score_all_xs> (each thread scans its own
slice of the packed query buffer) and C<use_openmp_fit> (each thread
builds one tree from packed training data) benefit from this when the

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN


	if ( defined( $args{seed} ) ) {
		$args{seed} = abs( int( $args{seed} ) );
	}

	# Clamp the accel knobs against what the build actually has.  Passing
	# use_c => 1 on a machine where Inline::C never compiled would otherwise
	# leave score_samples() calling an undefined XS sub at first use.
	# OpenMP is meaningless without the C tree walk, so force it off
	# whenever the C backend is off -- matches the documented
	# "Ignored when use_c is false" semantics.
	my $use_c
		= defined $args{use_c}
		? ( $args{use_c} && $HAS_C ? 1 : 0 )
		: $HAS_C;
	my $use_openmp
		= defined $args{use_openmp}
		? ( $args{use_openmp} && $HAS_OPENMP ? 1 : 0 )
		: $HAS_OPENMP;
	$use_openmp = 0 unless $use_c;

	# Opt-in only (default 0, not $HAS_OPENMP): this path changes which
	# trees fit() builds (see docs above), unlike use_c/use_openmp which
	# only change speed.  Clamped the same way use_openmp is.
	my $use_openmp_fit = ( $args{use_openmp_fit} && $HAS_OPENMP && $use_c ) ? 1 : 0;

	my $self = {
		n_trees              => $args{n_trees}     // 100,
		sample_size          => $args{sample_size} // 256,
		max_depth            => $args{max_depth},              # undef => auto
		seed                 => $args{seed},                   # undef => non-deterministic
		mode                 => $mode,
		extension_level      => $args{extension_level},        # undef => max, resolved in fit()
		contamination        => $args{contamination},          # undef => no learned threshold
		parallel_fit         => $args{parallel_fit},           # undef/0/1 => serial; N>1 => fork
		missing              => $missing,                      # die|zero|impute|nan
		impute_with          => $impute_with,                  # mean|median (impute mode only)
		voting               => $voting,                       # mean|majority (scoring-time aggregation)
		missing_fill         => undef,                         # per-feature fill, learned in fit() if impute
		_use_c               => $use_c,
		_use_openmp          => $use_openmp,
		_use_openmp_fit      => $use_openmp_fit,
		threshold            => undef,                         # learned in fit() if contamination set
		trees                => [],
		c_psi                => undef,                         # c(psi), set during fit()
		n_features           => undef,
		feature_names        => $args{feature_names},          # optional arrayref of per-feature labels
		mungers              => undef,                         # optional Algorithm::ToNumberMunger spec hash
															   # Opaque schema metadata, usually set via new_from_prototype and
															   # persisted with the model.  Never parsed -- documentation that
															   # travels with the model file.
		schema_version       => $args{schema_version},
		schema_description   => $args{schema_description},
		feature_descriptions => $args{feature_descriptions},
	};

	for my $doc (qw(schema_version schema_description)) {
		croak "$doc must be a plain string"
			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.

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

        "max_leaf_samples": 32,
        "contamination": 0.02
      }
    }

The fields, top to bottom...

  - format :: required, always the string
        'Algorithm::Classifier::IsolationForest::Prototype'.  A prototype
        handed to load() (or a model handed to the prototype methods)
        dies with a clear message instead of half-working.

  - version :: the prototype format version; this release reads version 1.
      default :: 1

  - class :: required, 'batch' (this class) or 'online'
        (L<Algorithm::Classifier::IsolationForest::Online>).  Prototypes
        are self-describing; `iforest fit` refuses an online prototype
        and `iforest stream` refuses a batch one.  Two model types with
        the same variables means two prototype files.

  - schema_version :: required opaque string, never parsed or compared
        numerically.  User-owned: bump it when the variable schema
        changes.

  - schema_description :: required opaque free-text string describing
        what this variable schema is, so a model file explains itself
        months later.

  - schema :: required object holding the variable schema.
        feature_names is required (order = CSV column order); the
        optional keys are feature_descriptions ('feature name => free
        text', every key must name an entry in feature_names, partial
        coverage fine), mungers (see L</MUNGERS>), missing, and -- batch
        prototypes only -- impute_with.  Unknown keys croak.

  - params :: optional object of tuning knobs, whitelisted per class.
        Batch: n_trees, sample_size, max_depth, mode, extension_level,
        contamination, voting, seed.  Online: n_trees, window_size,
        max_leaf_samples, growth, subsample, contamination, seed.
        Unknown keys croak -- a typo'd knob silently falling back to its
        default is exactly the failure mode a prototype exists to
        prevent.  Machine-local knobs (use_c, use_openmp, use_openmp_fit,
        parallel_fit) are rejected: they describe the box the model runs
        on, not the model.

=cut

# Per-class whitelists for a prototype's params block (and
# new_from_prototype's %overrides) and its schema block.  Machine-local
# knobs are deliberately absent from the params lists.
my %PROTO_PARAM_KEYS = (
	batch  => { map { $_ => 1 } qw(n_trees sample_size max_depth mode extension_level contamination voting seed) },
	online => { map { $_ => 1 } qw(n_trees window_size max_leaf_samples growth subsample contamination seed) },
);
my %PROTO_SCHEMA_KEYS = (
	batch  => { map { $_ => 1 } qw(feature_names feature_descriptions mungers missing impute_with) },
	online => { map { $_ => 1 } qw(feature_names feature_descriptions mungers missing) },
);

=head2 validate_prototype($proto)

Structurally validates a prototype -- a hashref or a JSON string -- and
returns the decoded hashref; croaks describing the first problem found.
Validation is structural only (no munger compilation), so it does not
require Algorithm::ToNumberMunger even for a munger-bearing prototype.

    my $proto = Algorithm::Classifier::IsolationForest->validate_prototype($json);

=cut

sub validate_prototype {
	my ( $class, $proto ) = @_;

	if ( !ref $proto ) {
		my $decoded = eval { JSON::PP->new->decode($proto) };
		croak "prototype did not parse as JSON: $@" if $@;
		$proto = $decoded;
	}
	croak "not an IsolationForest prototype (expected a JSON object)"
		unless ref $proto eq 'HASH';
	croak "not an IsolationForest prototype (format is not " . "'Algorithm::Classifier::IsolationForest::Prototype')"
		unless defined $proto->{format}
		&& !ref $proto->{format}
		&& $proto->{format} eq 'Algorithm::Classifier::IsolationForest::Prototype';

	my $version = $proto->{version} // 1;
	croak "prototype format version '$version' is newer than this module understands (max 1)"
		if !ref $version && $version =~ /^\d+$/ && $version > 1;

	for my $k ( sort keys %$proto ) {
		croak "prototype has unknown top-level key '$k'"
			unless $k =~ /\A(?:format|version|class|schema_version|schema_description|schema|params)\z/;
	}

	my $which = $proto->{class};
	croak "prototype needs a class of 'batch' or 'online'"
		unless defined $which && !ref $which && $which =~ /\A(?:batch|online)\z/;

	for my $req (qw(schema_version schema_description)) {
		croak "prototype needs a non-empty $req string"
			unless defined $proto->{$req} && !ref $proto->{$req} && length $proto->{$req};
	}

	my $schema = $proto->{schema};
	croak "prototype needs a schema object" unless ref $schema eq 'HASH';
	for my $k ( sort keys %$schema ) {
		croak "prototype schema has unknown key '$k' for a $which prototype (allowed: "
			. join( ', ', sort keys %{ $PROTO_SCHEMA_KEYS{$which} } ) . ')'
			unless $PROTO_SCHEMA_KEYS{$which}{$k};
	}
	my $tags = $schema->{feature_names};
	croak "prototype schema needs a non-empty feature_names array"
		unless ref $tags eq 'ARRAY' && @$tags;
	for my $t (@$tags) {
		croak "prototype feature_names entries must be non-empty strings"
			unless defined $t && !ref $t && length $t;
	}
	_validate_feature_descriptions( $tags, $schema->{feature_descriptions} )
		if defined $schema->{feature_descriptions};
	croak "prototype schema mungers must be an object of 'tag => munger spec'"
		if defined $schema->{mungers} && ref $schema->{mungers} ne 'HASH';
	for my $str (qw(missing impute_with)) {
		croak "prototype schema $str must be a plain string"
			if defined $schema->{$str} && ref $schema->{$str};
	}

	my $params = $proto->{params};
	croak "prototype params must be an object of tuning knobs"
		if defined $params && ref $params ne 'HASH';
	for my $k ( sort keys %{ $params || {} } ) {
		croak "prototype params has unknown key '$k' for a $which prototype (allowed: "
			. join( ', ', sort keys %{ $PROTO_PARAM_KEYS{$which} } )
			. '; machine-local knobs like use_c are deliberately not allowed)'
			unless $PROTO_PARAM_KEYS{$which}{$k};
	}

	return $proto;
} ## end sub validate_prototype

=head2 new_from_prototype($proto, %overrides)

Creates a fresh, unfitted model from a prototype (a hashref or a JSON
string) and returns it -- an instance of whichever class the prototype's
C<class> field names, so like C<load()> this is a single entry point for
both model types.  Croaks on any validation failure; a munger-bearing
prototype compiles its plan here, so a bogus munger spec dies at
creation (and needs Algorithm::ToNumberMunger installed).

C<%overrides> merge over the prototype's C<params> block -- per-run
knobs like C<seed> -- and are held to the same per-class whitelist.
Overriding the schema itself (feature_names, feature_descriptions,
mungers, missing, impute_with, schema_version, schema_description)
croaks: the schema is the prototype's, full stop; edit the prototype.

    my $oif = Algorithm::Classifier::IsolationForest->new_from_prototype(
        $proto_json,
        seed => 42,
    );

=cut

sub new_from_prototype {
	my ( $class, $proto, %overrides ) = @_;

	$proto = $class->validate_prototype($proto);
	my $which  = $proto->{class};
	my $schema = $proto->{schema};

	for my $k ( sort keys %overrides ) {
		croak "new_from_prototype: '$k' is part of the prototype's schema and may not "
			. "be overridden; edit the prototype instead"
			if $k
			=~ /\A(?:feature_names|feature_descriptions|mungers|missing|impute_with|schema_version|schema_description)\z/;
		croak "new_from_prototype: unknown override '$k' for a $which prototype (allowed: "
			. join( ', ', sort keys %{ $PROTO_PARAM_KEYS{$which} } ) . ')'
			unless $PROTO_PARAM_KEYS{$which}{$k};
	}

	my %args = (
		%{ $proto->{params} || {} },
		%overrides,
		feature_names      => $schema->{feature_names},
		schema_version     => $proto->{schema_version},
		schema_description => $proto->{schema_description},
	);
	for my $k (qw(feature_descriptions mungers missing impute_with)) {
		$args{$k} = $schema->{$k} if defined $schema->{$k};
	}

	if ( $which eq 'online' ) {
		require Algorithm::Classifier::IsolationForest::Online;
		return Algorithm::Classifier::IsolationForest::Online->new(%args);
	}
	return Algorithm::Classifier::IsolationForest->new(%args);
} ## end sub new_from_prototype

=head2 load_prototype($path, %overrides)

L</new_from_prototype($proto, %overrides)> from a file.

    my $iforest = Algorithm::Classifier::IsolationForest->load_prototype(
        'proto.json', seed => 42 );

=cut

sub load_prototype {
	my ( $class, $path, %overrides ) = @_;
	my $raw = read_file($path);
	return $class->new_from_prototype( $raw, %overrides );
}

=head2 to_prototype

Returns a prototype JSON string extracted from this model: its variable
schema (feature_names, feature_descriptions, mungers, missing policy)
plus its current tuning knobs.  This closes the loop -- extract a
prototype from a good model and periodically create fresh models with an
identical schema, the natural retrain workflow -- and means hand-writing
a prototype is never mandatory.

Croaks when the model has no C<feature_names>: a prototype's variable
schema needs named variables.  A model with no recorded
C<schema_version> / C<schema_description> (fitted before prototype
support, or without the knobs) gets placeholder values, since both are
required in the file -- edit them in and bump from there.  C<seed> and

lib/Algorithm/Classifier/IsolationForest.pm  view on Meta::CPAN

					push @coef_val, $coef_for{$k} + 0.0;
				}
			} else {
				for my $i ( 0 .. $num - 1 ) {
					push @coef_idx, int( $idx_arr->[$i] );
					push @coef_val, $coef_arr->[$i] + 0.0;
				}
			}

			my $li = $assign->( $node->[4] );
			my $ri = $assign->( $node->[5] );
			$node_data[$my_idx] = [ 2.0, $coef_off + 0.0, $num + 0.0, $li + 0.0, $ri + 0.0, $b + 0.0, ];
		} ## end else [ if ( $node->[0] == _NODE_LEAF ) ]
		return $my_idx;
	}; ## end $assign = sub
	$assign->($root);

	my $nodes_packed = pack( 'd*', map { @$_ } @node_data );
	my $idx_packed   = @coef_idx ? pack( 'l*', @coef_idx ) : pack('l*');
	my $val_packed   = @coef_val ? pack( 'd*', @coef_val ) : pack('d*');
	return ( $nodes_packed, $idx_packed, $val_packed );
} ## end sub _pack_tree

# Build packed C-ready representations for all trees and store them in
# $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val}.
# Called after fit() and from_json() when _use_c is true.  n_features is
# threaded through so _pack_tree can spot the dense-pack opportunity.
sub _rebuild_c_trees {
	my ($self) = @_;
	my ( @c_nodes, @c_coef_idx, @c_coef_val );
	for my $tree ( @{ $self->{trees} } ) {
		my ( $np, $ip, $vp ) = _pack_tree( $tree, $self->{n_features} );
		push @c_nodes,    $np;
		push @c_coef_idx, $ip;
		push @c_coef_val, $vp;
	}
	$self->{_c_nodes}    = \@c_nodes;
	$self->{_c_coef_idx} = \@c_coef_idx;
	$self->{_c_coef_val} = \@c_coef_val;
} ## end sub _rebuild_c_trees

sub _check_fitted {
	my ($self) = @_;
	croak "model is not fitted yet; call fit() first"
		unless ref $self->{trees} eq 'ARRAY' && @{ $self->{trees} };
}

# ---------------------------------------------------------------------------
# Optional Algorithm::ToNumberMunger integration -- see the MUNGERS POD
# section.  Both helpers are plain functions (not methods) so the Online
# class's delegating methods can hand them their own $self: the plan and
# spec live in the same hash slots on either class.
# ---------------------------------------------------------------------------

# Validate a feature_descriptions hash against the feature names: every
# described feature must exist (a description for one that does not is
# either a typo or a stale leftover from a schema change) and every
# description must be a plain string.  Partial coverage is fine.  A plain
# function, like the munger helpers, so both classes and the prototype
# validator can call it.
sub _validate_feature_descriptions {
	my ( $tags, $fd ) = @_;
	croak "feature_descriptions must be a hashref of 'feature name => description'"
		unless ref $fd eq 'HASH';
	croak "feature_descriptions requires feature_names to validate against"
		unless ref $tags eq 'ARRAY' && @$tags;
	my %known = map { $_ => 1 } @$tags;
	for my $k ( sort keys %$fd ) {
		croak "feature_descriptions describes '$k', which is not in feature_names"
			unless $known{$k};
		croak "feature_descriptions entry for '$k' must be a plain string"
			if ref $fd->{$k};
	}
	return 1;
} ## end sub _validate_feature_descriptions

# Compile a munger spec against the model's feature names.  Requires
# Algorithm::ToNumberMunger on demand -- it is an optional dependency --
# and lets its compile() croak on any spec problem.
sub _compile_mungers {
	my ( $tags, $mungers ) = @_;
	croak "this model has mungers but no feature_names to compile them against"
		unless ref $tags eq 'ARRAY' && @$tags;
	local $@;
	eval { require Algorithm::ToNumberMunger; 1 }
		or croak "this model has mungers configured but Algorithm::ToNumberMunger "
		. "could not be loaded; install it to use tagged data with this model: $@";
	return Algorithm::ToNumberMunger->compile(
		tags    => $tags,
		mungers => $mungers,
	);
} ## end sub _compile_mungers

# The compiled plan for this model, or undef when no mungers are
# configured.  Compiled lazily (memoised in _munger_plan) so from_json
# does not need Algorithm::ToNumberMunger installed unless tagged data
# is actually used; new() populates the slot eagerly instead, surfacing
# spec errors at construction.
sub _plan {
	my ($self) = @_;
	return undef unless $self->{mungers};
	$self->{_munger_plan} //= _compile_mungers( $self->{feature_names}, $self->{mungers} );
	return $self->{_munger_plan};
}

# Memoised "does this perl have a real fork()?".  False on Windows
# without Cygwin; true on every Unix-like platform.
{
	my $cached;

	sub _fork_supported {
		return $cached if defined $cached;
		require Config;
		$cached
			= ( ( $Config::Config{d_fork} || '' ) eq 'define' ) ? 1 : 0;
		return $cached;
	}
}

#-------------------------------------------------------------------------------
# Fork-based parallel tree builder.  Used by fit() when parallel_fit > 1
# and the platform has a real fork().  Divides n_trees evenly among
# workers; each child seeds its own RNG ($seed + worker_id * 1009 so
# fixed-worker-count runs are reproducible), builds its share (via the
# C builder when _use_c is on, same as the non-parallel path), and
# returns the trees to the parent via Storable on a one-shot pipe.
#
# The trees that come back differ from a serial fit with the same seed
# because the RNG draws happen in a different order -- this is documented
# as part of the parallel_fit contract.
#-------------------------------------------------------------------------------
sub _fit_trees_parallel {
	my ( $self, $data, $psi, $limit, $workers ) = @_;
	require Storable;
	require POSIX;



( run in 0.561 second using v1.01-cache-2.11-cpan-f4a522933cf )