File Coverage

blib/lib/Algorithm/Classifier/IsolationForest.pm
Criterion Covered Total %
statement 565 626 90.2
branch 225 270 83.3
condition 132 177 74.5
subroutine 51 57 89.4
pod 18 18 100.0
total 991 1148 86.3


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest;
2              
3 42     42   2268776 use strict;
  42         70  
  42         1294  
4 42     42   159 use warnings;
  42         82  
  42         1843  
5 42     42   188 use Carp qw(croak);
  42         73  
  42         2121  
6 42     42   253 use List::Util qw(min);
  42         140  
  42         3597  
7 42     42   17631 use POSIX qw(ceil);
  42         254995  
  42         247  
8 42     42   69676 use JSON::PP ();
  42         391132  
  42         1117  
9 42     42   19447 use File::Slurp qw(read_file write_file);
  42         749884  
  42         3230  
10              
11             our $VERSION = '0.4.0';
12              
13 42     42   323 use constant EULER => 0.5772156649015329;
  42         98  
  42         2172  
14 42     42   189 use constant TWO_PI => 6.283185307179586;
  42         95  
  42         1385  
15              
16             # Node-type tags stored in index 0 of every tree node arrayref.
17             # 0 is falsy, so while ($node->[0]) acts as while (!leaf).
18 42     42   181 use constant _NODE_LEAF => 0;
  42         64  
  42         1194  
19 42     42   132 use constant _NODE_AXIS => 1;
  42         55  
  42         1285  
20 42     42   150 use constant _NODE_OBLIQUE => 2;
  42         59  
  42         357500  
21              
22             # ---------------------------------------------------------------------------
23             # Optional Inline::C accelerator for the scoring hot path.
24             #
25             # pack_input_xs(data_sv, out_sv, n_pts, n_feats, miss_mode, fill_sv)
26             # Walks the Perl arrayref-of-arrayrefs and writes a packed double buffer
27             # into out_sv. Replaces the dominant per-call Perl map-pack loop.
28             # miss_mode selects how an undef cell is packed: 0 => 0.0, 1 => the
29             # per-feature fill from fill_sv (impute), 2 => NaN (nan strategy).
30             #
31             # score_all_xs(nodes_av, idx_av, val_av, x_sv, sm_sv,
32             # n_pts, n_feats, n_trees, use_openmp)
33             # Sums path lengths for all n_pts query points across all n_trees trees
34             # in one call. Outer loop over points is OpenMP-parallel when the
35             # module was built with OpenMP (each iteration writes to a unique sm[i],
36             # so no synchronisation is needed). Tree pointers are extracted from
37             # the AVs before the parallel region; the parallel region touches only
38             # raw int / double buffers.
39             #
40             # Node layout (6 doubles per node, "IF_NZ = 6"):
41             # leaf: [0, size, c(size), 0, 0, 0]
42             # axis: [1, attr, split, li, ri, 0]
43             # oblique: [2, coff, nf, li, ri, b]
44             #
45             # c(size) is the expected-path-length adjustment for a leaf holding
46             # `size` points, precomputed by _pack_tree (it involves a log(); doing
47             # it at pack time keeps transcendentals out of the per-point per-tree
48             # scoring loop). The fit-time TreeBuf writer leaves that slot 0 --
49             # its buffers are unpacked into Perl trees and re-packed by
50             # _pack_tree before score_all_xs ever sees them.
51             #
52             # Coefficient storage uses a Structure-of-Arrays layout: one int32 array
53             # per tree (feature indices, packed with 'l*') and one double array per
54             # tree (coefficients, packed with 'd*'). Both are indexed by `coff` --
55             # the same offset addresses paired entries in the two arrays. Splitting
56             # them this way halves index bandwidth, removes the per-element
57             # (int) cast inside the SIMD loop, and lets the value loads be
58             # contiguous so the compiler emits a clean FMA chain over val[k] with
59             # the feature gather on xi[idx[k]] kept separate.
60             #
61             # Dense-pack fast path: when an oblique node uses every feature (the
62             # common case in extended mode with extension_level == n_features - 1),
63             # _pack_tree writes its coefficients in feature order so val[k] is the
64             # coefficient for feature k. score_all_xs detects this via `nf ==
65             # n_feats` and uses a no-gather dot product (dot += val[k] * xi[k])
66             # that vectorizes cleanly with FMA -- substantially faster than the
67             # sparse gather path on high-feature-count models.
68             # x: row-major doubles, n_pts rows of n_feats each.
69             # sums: out double array of length n_pts; score_all_xs writes once per i.
70             #
71             # OpenMP is enabled at module load when the toolchain accepts -fopenmp and
72             # libgomp is linkable; otherwise the same C code compiles to a serial loop
73             # (the #pragma is silently ignored without _OPENMP defined).
74             # ---------------------------------------------------------------------------
75             our $HAS_C = 0;
76             our $HAS_OPENMP = 0;
77             our $HAS_SIMD = 0;
78             our $OPT_LEVEL = ''; # the actual -O.../-march=... flags used to build, if any
79             our $C_SOURCE = ''; # 'prebuilt' (object installed at `make` time) or
80             # 'runtime' (compiled at first load into _Inline/);
81             # '' when $HAS_C is 0
82             {
83             my $C_CODE = <<'__INLINE_C__';
84             #include
85             #include
86             #include
87             #ifdef _OPENMP
88             #include
89             #endif
90             #define IF_NZ 6
91              
92             /* Data prefetch hint; a no-op on compilers without __builtin_prefetch.
93             * Purely a performance hint -- never affects results. */
94             #if defined(__GNUC__) || defined(__clang__)
95             #define IF_PREFETCH(p) __builtin_prefetch(p)
96             #else
97             #define IF_PREFETCH(p)
98             #endif
99              
100             int has_openmp_xs(){
101             #ifdef _OPENMP
102             return 1;
103             #else
104             return 0;
105             #endif
106             }
107              
108             /* SIMD on the extended-mode oblique dot product is enabled via
109             * `#pragma omp simd`, which OpenMP 4.0 (_OPENMP == 201307) introduced.
110             * Anything older silently ignores the pragma -- the loop still runs,
111             * just not auto-vectorised. So "simd available" really means the
112             * compiler is going to honour the pragma we put on that loop. */
113             int has_simd_xs(){
114             #if defined(_OPENMP) && _OPENMP >= 201307
115             return 1;
116             #else
117             return 0;
118             #endif
119             }
120              
121             /* pack_input_xs(data_sv, out_sv, n_pts, n_feats, miss_mode, fill_sv)
122             *
123             * Walks a Perl arrayref-of-arrayrefs (n_pts rows of n_feats doubles each)
124             * directly in C and writes the packed double buffer into out_sv (which the
125             * caller pre-allocates with "\0" x (n_pts*n_feats*8)). Replaces
126             *
127             * pack('d*', map { my $r=$_; map { $r->[$_] // 0 } 0..$nf-1 } @$data)
128             *
129             * which was the dominant per-call overhead for high feature counts.
130             *
131             * miss_mode selects what an undef cell (or missing row) becomes:
132             * 0 => 0.0 (the 'die'/'zero' missing strategies)
133             * 1 => fill[k] (the 'impute' strategy; fill_sv is a packed
134             * double buffer of n_feats per-feature fill values)
135             * 2 => NaN (the 'nan' strategy; the C scorer's `<` / `<=`
136             * comparisons are both false for NaN, so a point
137             * missing the split feature falls to the right
138             * child -- matching how fit() routes it)
139             * fill_sv is only dereferenced when miss_mode == 1. */
140             void pack_input_xs(SV* data_sv, SV* out_sv, int n_pts, int n_feats,
141             int miss_mode, SV* fill_sv){
142             STRLEN tl;
143             double* out;
144             const double* fill = NULL;
145             double missval;
146             AV* outer;
147             int i, k;
148              
149             if (!SvROK(data_sv) || SvTYPE(SvRV(data_sv)) != SVt_PVAV) {
150             croak("pack_input_xs: data must be an arrayref");
151             }
152             outer = (AV*)SvRV(data_sv);
153             out = (double*)SvPVbyte_force(out_sv, tl);
154              
155             if (miss_mode == 1) {
156             STRLEN fl;
157             fill = (const double*)SvPVbyte(fill_sv, fl);
158             }
159             missval = (miss_mode == 2) ? NAN : 0.0;
160              
161             for (i = 0; i < n_pts; i++) {
162             SV** row_pp = av_fetch(outer, i, 0);
163             double* dst = out + (size_t)i * (size_t)n_feats;
164             if (!row_pp || !*row_pp || !SvROK(*row_pp) ||
165             SvTYPE(SvRV(*row_pp)) != SVt_PVAV) {
166             for (k = 0; k < n_feats; k++)
167             dst[k] = (miss_mode == 1) ? fill[k] : missval;
168             continue;
169             }
170             {
171             AV* row = (AV*)SvRV(*row_pp);
172             for (k = 0; k < n_feats; k++) {
173             SV** v = av_fetch(row, k, 0);
174             if (v && *v && SvOK(*v)) {
175             dst[k] = SvNV(*v);
176             } else {
177             dst[k] = (miss_mode == 1) ? fill[k] : missval;
178             }
179             }
180             }
181             }
182             }
183              
184             /* finalize_scores_xs(sm_sv, n_pts, inv, out_rv)
185             *
186             * Fills the pre-allocated arrayref out_rv with exp(-sm[i] * inv) for
187             * i in 0..n_pts-1. Replaces the trailing
188             *
189             * my @sums = unpack('d*', $sums_packed);
190             * return [ map { exp(-$_ * $inv) } @sums ];
191             *
192             * which allocated ~2*n_pts intermediate Perl SVs per scoring call. */
193             void finalize_scores_xs(SV* sm_sv, int n_pts, double inv, SV* out_rv){
194             STRLEN tl;
195             const double* sm;
196             AV* out;
197             int i;
198              
199             if (!SvROK(out_rv) || SvTYPE(SvRV(out_rv)) != SVt_PVAV) {
200             croak("finalize_scores_xs: out must be an arrayref");
201             }
202             sm = (const double*)SvPVbyte(sm_sv, tl);
203             out = (AV*)SvRV(out_rv);
204             av_clear(out);
205             if (n_pts > 0) av_extend(out, n_pts - 1);
206             for (i = 0; i < n_pts; i++) {
207             av_store(out, i, newSVnv(exp(-sm[i] * inv)));
208             }
209             }
210              
211             /* finalize_path_lengths_xs(sm_sv, n_pts, t, out_rv)
212             *
213             * Same idea as finalize_scores_xs but writes sm[i] / t (the average path
214             * length across n_trees=t trees) instead of the exp normalisation. */
215             void finalize_path_lengths_xs(SV* sm_sv, int n_pts, double t, SV* out_rv){
216             STRLEN tl;
217             const double* sm;
218             AV* out;
219             int i;
220              
221             if (!SvROK(out_rv) || SvTYPE(SvRV(out_rv)) != SVt_PVAV) {
222             croak("finalize_path_lengths_xs: out must be an arrayref");
223             }
224             sm = (const double*)SvPVbyte(sm_sv, tl);
225             out = (AV*)SvRV(out_rv);
226             av_clear(out);
227             if (n_pts > 0) av_extend(out, n_pts - 1);
228             for (i = 0; i < n_pts; i++) {
229             av_store(out, i, newSVnv(sm[i] / t));
230             }
231             }
232              
233             /* predict_sums_xs(sm_sv, n_pts, sum_threshold, out_rv)
234             *
235             * Fills out_rv with 0/1 IVs based on sm[i] <= sum_threshold. The caller
236             * pre-computes sum_threshold = -log(score_threshold) * c * n_trees / log(2),
237             * so this skips both the per-point exp() and the intermediate scores
238             * arrayref that the old "score_samples + map threshold" path created. */
239             void predict_sums_xs(SV* sm_sv, int n_pts, double sum_threshold, SV* out_rv){
240             STRLEN tl;
241             const double* sm;
242             AV* out;
243             int i;
244              
245             if (!SvROK(out_rv) || SvTYPE(SvRV(out_rv)) != SVt_PVAV) {
246             croak("predict_sums_xs: out must be an arrayref");
247             }
248             sm = (const double*)SvPVbyte(sm_sv, tl);
249             out = (AV*)SvRV(out_rv);
250             av_clear(out);
251             if (n_pts > 0) av_extend(out, n_pts - 1);
252             for (i = 0; i < n_pts; i++) {
253             av_store(out, i, newSViv(sm[i] <= sum_threshold ? 1 : 0));
254             }
255             }
256              
257             /* score_predict_xs(sm_sv, n_pts, inv, sum_threshold, out_rv)
258             *
259             * Combines finalize_scores_xs + predict_sums_xs: fills the pre-allocated
260             * out_rv with [score, label] pairs in one pass over sm_sv. Replaces the
261             * trailing Perl loop in score_predict_samples that built ~3*n_pts SVs
262             * (n_pts scores + n_pts labels + n_pts inner arrayrefs) via a Perl
263             * foreach -- here the same SVs are allocated directly inside C.
264             *
265             * Refcount note: newRV_noinc takes ownership of the inner AV without
266             * incrementing it, and av_store takes ownership of the RV. When the
267             * outer AV is destroyed it frees the RVs, which free the inner AVs,
268             * which free the score/label SVs. No leak. */
269             void score_predict_xs(SV* sm_sv, int n_pts, double inv,
270             double sum_threshold, SV* out_rv){
271             STRLEN tl;
272             const double* sm;
273             AV* out;
274             int i;
275              
276             if (!SvROK(out_rv) || SvTYPE(SvRV(out_rv)) != SVt_PVAV) {
277             croak("score_predict_xs: out must be an arrayref");
278             }
279             sm = (const double*)SvPVbyte(sm_sv, tl);
280             out = (AV*)SvRV(out_rv);
281             av_clear(out);
282             if (n_pts > 0) av_extend(out, n_pts - 1);
283             for (i = 0; i < n_pts; i++) {
284             AV* row = newAV();
285             av_extend(row, 1);
286             /* av_extend filled both slots with &PL_sv_undef. Since that
287             * sentinel is immortal (its refcount is never freed) we can
288             * overwrite the slots directly and bump AvFILLp, skipping the
289             * per-element bounds/magic checks av_store would do. */
290             AvARRAY(row)[0] = newSVnv(exp(-sm[i] * inv));
291             AvARRAY(row)[1] = newSViv(sm[i] <= sum_threshold ? 1 : 0);
292             AvFILLp(row) = 1;
293             av_store(out, i, newRV_noinc((SV*)row));
294             }
295             }
296              
297             /* score_predict_split_xs(sm_sv, n_pts, inv, sum_threshold,
298             * scores_rv, labels_rv)
299             *
300             * Parallel-arrays variant of score_predict_xs: fills two pre-allocated
301             * arrayrefs (scores: NV, labels: IV) instead of an AV-of-[score, label]
302             * pairs. Allocates ~2*n_pts SVs instead of ~4*n_pts -- no inner AV and
303             * no RV per point -- so it's about twice as cheap for callers that
304             * don't need the paired shape. */
305             void score_predict_split_xs(SV* sm_sv, int n_pts, double inv,
306             double sum_threshold,
307             SV* scores_rv, SV* labels_rv){
308             STRLEN tl;
309             const double* sm;
310             AV* scores;
311             AV* labels;
312             int i;
313              
314             if (!SvROK(scores_rv) || SvTYPE(SvRV(scores_rv)) != SVt_PVAV ||
315             !SvROK(labels_rv) || SvTYPE(SvRV(labels_rv)) != SVt_PVAV) {
316             croak("score_predict_split_xs: scores/labels must be arrayrefs");
317             }
318             sm = (const double*)SvPVbyte(sm_sv, tl);
319             scores = (AV*)SvRV(scores_rv);
320             labels = (AV*)SvRV(labels_rv);
321             av_clear(scores);
322             av_clear(labels);
323             if (n_pts > 0) {
324             av_extend(scores, n_pts - 1);
325             av_extend(labels, n_pts - 1);
326             }
327             for (i = 0; i < n_pts; i++) {
328             av_store(scores, i, newSVnv(exp(-sm[i] * inv)));
329             av_store(labels, i, newSViv(sm[i] <= sum_threshold ? 1 : 0));
330             }
331             }
332              
333             /* Walk one point through one tree; returns the path length (depth plus
334             * the precomputed c(leaf size) adjustment from the leaf record).
335             *
336             * Invariant: every feature index stored in a tree node is in
337             * [0, n_feats). fit() builds trees against n_features columns and
338             * pack_input_xs writes exactly that many doubles per row, and
339             * _resolve_input rejects PackedData with a mismatched feature count.
340             * So the loop can omit per-iteration bounds checks on attr / fi --
341             * this is what lets the oblique dot product vectorize cleanly under
342             * the omp-simd reductions below. */
343             #if defined(__GNUC__) || defined(__clang__)
344             __attribute__((always_inline))
345             #endif
346             static inline double if_walk_tree(const double *nd, const int *ico,
347             const double *vco, const double *xi,
348             int n_feats) {
349             int ni = 0, depth = 0;
350             for (;;) {
351             const double *node = nd + (size_t)ni * IF_NZ;
352             int type = (int)node[0];
353             if (type == 0) {
354             /* node[2] is c(leaf size), precomputed by _pack_tree; a
355             * log() here would otherwise run once per point per tree. */
356             return depth + node[2];
357             }
358             if (type == 1) {
359             double fv = xi[(int)node[1]];
360             ni = (fv < node[2]) ? (int)node[3] : (int)node[4];
361             } else {
362             int coff = (int)node[1], nf = (int)node[2];
363             double b = node[5], dot = 0.0;
364             const double *val_p = vco + (size_t)coff;
365              
366             /* Both children are known before the dot product resolves
367             * which one gets taken, so start pulling their records in
368             * now and let the FMA loop below hide the latency. One of
369             * the two prefetches is always wasted -- affordable here
370             * on the oblique path, where there is real work to hide it
371             * under, but not on the axis path, whose single compare
372             * resolves immediately. */
373             const int li = (int)node[3], ri = (int)node[4];
374             IF_PREFETCH(nd + (size_t)li * IF_NZ);
375             IF_PREFETCH(nd + (size_t)ri * IF_NZ);
376             if (nf == n_feats) {
377             /* Dense oblique split: this node uses every feature,
378             * so _pack_tree laid the coefficients out in feature
379             * order. No gather -- the inner loop is a textbook
380             * FMA-vectorizable dot product over two contiguous
381             * double streams. Common case in extended mode at
382             * the default extension_level (== n_feats-1). */
383             #ifdef _OPENMP
384             #pragma omp simd reduction(+:dot)
385             #endif
386             for (int k = 0; k < n_feats; k++) {
387             dot += val_p[k] * xi[k];
388             }
389             } else {
390             /* Sparse oblique split: only nf < n_feats features
391             * participate, so we still need the gather on
392             * xi[idx_p[k]]. Storing idx as contiguous int32
393             * (rather than interleaved doubles) keeps the gather
394             * pattern clean and the val[] load contiguous. */
395             const int *idx_p = ico + (size_t)coff;
396             #ifdef _OPENMP
397             #pragma omp simd reduction(+:dot)
398             #endif
399             for (int k = 0; k < nf; k++) {
400             dot += val_p[k] * xi[idx_p[k]];
401             }
402             }
403             ni = (dot <= b) ? li : ri;
404             }
405             depth++;
406             }
407             }
408              
409             /* score_all_xs(nodes_av, idx_av, val_av, x_sv, sm_sv,
410             * n_pts, n_feats, n_trees, use_openmp)
411             *
412             * Scores all points across all trees in one C call. See header comment
413             * above for the bigger picture. Writes sm[i] = sum_over_trees(path_len);
414             * the caller need not zero-init sm.
415             *
416             * idx_av holds per-tree packed int32 buffers of feature indices and
417             * val_av holds per-tree packed double buffers of coefficients (the SoA
418             * counterpart of the old interleaved layout). See the file-top
419             * comment for the rationale.
420             *
421             * Thread-safety: the parallel region only reads node/idx/val/x pointers
422             * (extracted before the region) and writes sm[i] for a unique i per
423             * iteration. No Perl API is called from inside the parallel region. */
424             void score_all_xs(SV* nodes_av_sv, SV* idx_av_sv, SV* val_av_sv,
425             SV* x_sv, SV* sm_sv,
426             int n_pts, int n_feats, int n_trees,
427             int use_openmp){
428             STRLEN tl;
429             AV *nodes_av, *idx_av, *val_av;
430             const double *xd;
431             double *sm;
432             int ti;
433              
434             if (!SvROK(nodes_av_sv) || SvTYPE(SvRV(nodes_av_sv)) != SVt_PVAV ||
435             !SvROK(idx_av_sv) || SvTYPE(SvRV(idx_av_sv)) != SVt_PVAV ||
436             !SvROK(val_av_sv) || SvTYPE(SvRV(val_av_sv)) != SVt_PVAV) {
437             croak("score_all_xs: nodes/idx/val must be arrayrefs");
438             }
439             nodes_av = (AV*)SvRV(nodes_av_sv);
440             idx_av = (AV*)SvRV(idx_av_sv);
441             val_av = (AV*)SvRV(val_av_sv);
442              
443             /* C99 VLAs -- n_trees is small (typ. 100) and fits on the stack. */
444             const double *node_ptrs[n_trees];
445             const int *idx_ptrs[n_trees];
446             const double *val_ptrs[n_trees];
447              
448             /* forest_bytes totals every buffer the tree walks touch; it decides
449             * between the two loop shapes below. */
450             size_t forest_bytes = 0;
451             for (ti = 0; ti < n_trees; ti++) {
452             SV** np = av_fetch(nodes_av, ti, 0);
453             SV** ip = av_fetch(idx_av, ti, 0);
454             SV** vp = av_fetch(val_av, ti, 0);
455             if (!np || !*np || !ip || !*ip || !vp || !*vp) {
456             croak("score_all_xs: missing tree %d", ti);
457             }
458             node_ptrs[ti] = (const double*)SvPVbyte(*np, tl); forest_bytes += tl;
459             idx_ptrs[ti] = (const int*) SvPVbyte(*ip, tl); forest_bytes += tl;
460             val_ptrs[ti] = (const double*)SvPVbyte(*vp, tl); forest_bytes += tl;
461             }
462              
463             xd = (const double*)SvPVbyte(x_sv, tl);
464             sm = (double*)SvPVbyte_force(sm_sv, tl);
465              
466             /* Two loop shapes over the same per-point ascending-t additions --
467             * bit-identical results either way, so the size heuristic choosing
468             * between them can never change scores.
469             *
470             * Point-major (small forests): each point walks all trees with its
471             * path-length sum held in a register. Cheapest per walk, and the
472             * whole forest stays cache-resident across points anyway.
473             *
474             * Tree-blocked (large forests): once the forest outgrows L3, the
475             * point-major loop re-streams every tree's nodes and coefficients
476             * from memory for every point -- an extended-mode tree is ~56 KB
477             * at 16 features (24 KB nodes + 32 KB dense coefficients), and its
478             * per-tree scoring cost measured 2.2x worse at 400 trees than at
479             * 100. Walking a block of points through ONE tree at a time keeps
480             * that tree hot in L1/L2 while the block's rows stream through it
481             * (measured 3.1x faster at 400 extended trees, 20k points). The
482             * blocked shape pays an sm[i] load+store per walk instead of a
483             * register add, which measurably hurts cheap axis walks while the
484             * forest still fits in cache -- hence the byte threshold rather
485             * than always tiling. */
486             if (forest_bytes <= (size_t)4 * 1024 * 1024) {
487             #ifdef _OPENMP
488             #pragma omp parallel for schedule(static) if(use_openmp)
489             #endif
490             for (int i = 0; i < n_pts; i++) {
491             const double *xi = xd + (size_t)i * (size_t)n_feats;
492             double sum = 0.0;
493             for (int t = 0; t < n_trees; t++) {
494             sum += if_walk_tree(node_ptrs[t], idx_ptrs[t],
495             val_ptrs[t], xi, n_feats);
496             }
497             sm[i] = sum;
498             }
499             }
500             else {
501             /* 256 rows x 16 features x 8 bytes = 32 KB of input per block
502             * -- comfortable in L2 next to one tree. Each OpenMP thread
503             * owns whole blocks and therefore a unique slice of sm[], so
504             * there is still no synchronisation. For small batches the
505             * tile shrinks to keep ~4 blocks per thread available; losing
506             * per-block tree reuse there is fine, since a small batch
507             * never re-streams much anyway. */
508             int tile = 256;
509             #ifdef _OPENMP
510             if (use_openmp) {
511             int min_blocks = omp_get_max_threads() * 4;
512             if (min_blocks > 0 && (n_pts + tile - 1) / tile < min_blocks) {
513             tile = (n_pts + min_blocks - 1) / min_blocks;
514             if (tile < 1) tile = 1;
515             }
516             }
517             #endif
518             int n_blocks = (n_pts + tile - 1) / tile;
519              
520             #ifdef _OPENMP
521             #pragma omp parallel for schedule(static) if(use_openmp)
522             #endif
523             for (int blk = 0; blk < n_blocks; blk++) {
524             const int i0 = blk * tile;
525             const int i1 = (i0 + tile < n_pts) ? i0 + tile : n_pts;
526             for (int i = i0; i < i1; i++) sm[i] = 0.0;
527             for (int t = 0; t < n_trees; t++) {
528             const double *nd = node_ptrs[t];
529             const int *ico = idx_ptrs[t];
530             const double *vco = val_ptrs[t];
531             for (int i = i0; i < i1; i++) {
532             sm[i] += if_walk_tree(nd, ico, vco,
533             xd + (size_t)i * (size_t)n_feats,
534             n_feats);
535             }
536             }
537             }
538             }
539             }
540              
541             /* ---------------------------------------------------------------------
542             * build_forest_xs -- C-accelerated fit() tree builder.
543             *
544             * Replaces the pure-Perl _subsample + _build_tree + _axis_split /
545             * _oblique_split recursion with an equivalent C implementation that
546             * partitions plain `int` row-index arrays instead of copying arrayrefs
547             * of Perl SVs at every split. Random draws go through Drand01() --
548             * the exact generator Perl's own rand()/srand() use internally -- in
549             * the same call order the Perl code used, so a fit() with a given
550             * seed produces BIT-IDENTICAL trees whether use_c is on or off. This
551             * is what lets fit() reuse the existing `use_c` knob instead of a new
552             * one: switching backends never changes the model, only how fast it's
553             * built. (Verified by t/02-accel-selection.t's "identical seed =>
554             * identical trees" subtest, which exercises both backends.)
555             *
556             * Output trees are plain Perl arrayrefs in the same node shape
557             * _build_tree produces (leaf/axis/oblique -- see the file-top
558             * comment), so every downstream consumer (_pack_tree, to_json,
559             * from_json, the pure-Perl scorer) is unchanged.
560             *
561             * x_sv: packed row-major double buffer, n_pts rows of n_feats each
562             * (from pack_input_xs -- NaN marks a missing cell under the
563             * 'nan' missing-strategy).
564             * mode_flag: 0 => axis-parallel splits, 1 => oblique (extended).
565             * ext_level: extension_level_used (ignored when mode_flag == 0).
566             * out_rv: pre-existing arrayref; filled with n_trees tree roots.
567             * ------------------------------------------------------------------ */
568              
569             /* Box-Muller normal draw, in the same rand() call order as _randn(). */
570             static double _c_randn(pTHX) {
571             double u1 = Drand01();
572             double u2;
573             if (u1 == 0.0) u1 = 1e-12;
574             u2 = Drand01();
575             return sqrt(-2.0 * log(u1)) * cos(6.283185307179586 * u2);
576             }
577              
578             static SV* _mk_leaf(pTHX_ int size) {
579             AV* av = newAV();
580             av_extend(av, 1);
581             AvARRAY(av)[0] = newSVnv(0.0);
582             AvARRAY(av)[1] = newSViv(size);
583             AvFILLp(av) = 1;
584             return newRV_noinc((SV*)av);
585             }
586              
587             static SV* _mk_axis(pTHX_ int attr, double split, SV* left, SV* right) {
588             AV* av = newAV();
589             av_extend(av, 4);
590             AvARRAY(av)[0] = newSVnv(1.0);
591             AvARRAY(av)[1] = newSViv(attr);
592             AvARRAY(av)[2] = newSVnv(split);
593             AvARRAY(av)[3] = left;
594             AvARRAY(av)[4] = right;
595             AvFILLp(av) = 4;
596             return newRV_noinc((SV*)av);
597             }
598              
599             static SV* _mk_oblique(pTHX_ const int* idx, const double* coef, int n,
600             double b, SV* left, SV* right) {
601             AV *iav, *cav, *av;
602             int k;
603             iav = newAV();
604             cav = newAV();
605             if (n > 0) {
606             av_extend(iav, n - 1);
607             av_extend(cav, n - 1);
608             }
609             for (k = 0; k < n; k++) {
610             AvARRAY(iav)[k] = newSViv(idx[k]);
611             AvARRAY(cav)[k] = newSVnv(coef[k]);
612             }
613             AvFILLp(iav) = n - 1;
614             AvFILLp(cav) = n - 1;
615              
616             av = newAV();
617             av_extend(av, 5);
618             AvARRAY(av)[0] = newSVnv(2.0);
619             AvARRAY(av)[1] = newRV_noinc((SV*)iav);
620             AvARRAY(av)[2] = newRV_noinc((SV*)cav);
621             AvARRAY(av)[3] = newSVnv(b);
622             AvARRAY(av)[4] = left;
623             AvARRAY(av)[5] = right;
624             AvFILLp(av) = 5;
625             return newRV_noinc((SV*)av);
626             }
627              
628             /* Builds one node from the point set `idxs` (row indices into `x`,
629             * length `size`); recurses left-then-right, matching _build_tree's
630             * traversal order so nested splits draw random numbers in the same
631             * sequence the pure-Perl path would. Takes ownership of `idxs` --
632             * frees it before returning. */
633             static SV* _build_node_c(pTHX_ const double* x, int nf, int* idxs, int size,
634             int depth, int limit, int mode_flag,
635             int ext_active) {
636             double *lo, *hi;
637             int *varying, nv, f;
638             SV *result;
639              
640             if (depth >= limit || size <= 1) {
641             SV* leaf = _mk_leaf(aTHX_ size);
642             free(idxs);
643             return leaf;
644             }
645              
646             lo = (double*)malloc(nf * sizeof(double));
647             hi = (double*)malloc(nf * sizeof(double));
648             for (f = 0; f < nf; f++) {
649             lo[f] = HUGE_VAL;
650             hi[f] = -HUGE_VAL;
651             }
652             for (int i = 0; i < size; i++) {
653             const double* row = x + (size_t)idxs[i] * (size_t)nf;
654             /* No isnan() guard needed: NaN < x and NaN > x are always false
655             * under IEEE 754, so a NaN cell (the 'nan' missing strategy)
656             * already leaves lo/hi untouched without an explicit check --
657             * one less branch, and it's what lets this loop vectorize
658             * cleanly as a plain elementwise min/max scan. */
659             #ifdef _OPENMP
660             #pragma omp simd
661             #endif
662             for (int f2 = 0; f2 < nf; f2++) {
663             double v = row[f2];
664             if (v < lo[f2]) lo[f2] = v;
665             if (v > hi[f2]) hi[f2] = v;
666             }
667             }
668              
669             varying = (int*)malloc(nf * sizeof(int));
670             nv = 0;
671             for (f = 0; f < nf; f++) {
672             if (lo[f] < hi[f]) varying[nv++] = f;
673             }
674              
675             if (nv == 0) {
676             free(lo); free(hi); free(varying);
677             SV* leaf = _mk_leaf(aTHX_ size);
678             free(idxs);
679             return leaf;
680             }
681              
682             if (mode_flag == 0) {
683             /* Axis-parallel split: one varying feature, one threshold. */
684             int attr = varying[(int)(Drand01() * nv)];
685             double split = lo[attr] + Drand01() * (hi[attr] - lo[attr]);
686             int *lidx = (int*)malloc(size * sizeof(int));
687             int *ridx = (int*)malloc(size * sizeof(int));
688             int ln = 0, rn = 0, i;
689             SV *left, *right;
690              
691             for (i = 0; i < size; i++) {
692             int row = idxs[i];
693             double v = x[(size_t)row * (size_t)nf + attr];
694             if (v < split) lidx[ln++] = row; else ridx[rn++] = row;
695             }
696             free(idxs); free(lo); free(hi); free(varying);
697              
698             left = _build_node_c(aTHX_ x, nf, lidx, ln, depth + 1, limit,
699             mode_flag, ext_active);
700             right = _build_node_c(aTHX_ x, nf, ridx, rn, depth + 1, limit,
701             mode_flag, ext_active);
702             result = _mk_axis(aTHX_ attr, split, left, right);
703             } else {
704             /* Oblique split: a random hyperplane over `active` features. */
705             int active = ext_active + 1;
706             int *pool, *lidx, *ridx;
707             double *coef;
708             double b = 0.0;
709             int ln = 0, rn = 0, i, k;
710             SV *left, *right;
711              
712             if (active > nv) active = nv;
713             pool = (int*)malloc(nv * sizeof(int));
714             memcpy(pool, varying, nv * sizeof(int));
715             for (i = 0; i < active; i++) {
716             int j = i + (int)(Drand01() * (nv - i));
717             int tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
718             }
719              
720             coef = (double*)malloc(active * sizeof(double));
721             for (k = 0; k < active; k++) {
722             int ff = pool[k];
723             double c = _c_randn(aTHX);
724             double p = lo[ff] + Drand01() * (hi[ff] - lo[ff]);
725             coef[k] = c;
726             b += c * p;
727             }
728              
729             lidx = (int*)malloc(size * sizeof(int));
730             ridx = (int*)malloc(size * sizeof(int));
731             for (i = 0; i < size; i++) {
732             int row = idxs[i];
733             double dot = 0.0;
734             for (k = 0; k < active; k++) {
735             dot += coef[k] * x[(size_t)row * (size_t)nf + pool[k]];
736             }
737             if (dot <= b) lidx[ln++] = row; else ridx[rn++] = row;
738             }
739             free(idxs); free(lo); free(hi); free(varying);
740              
741             left = _build_node_c(aTHX_ x, nf, lidx, ln, depth + 1, limit,
742             mode_flag, ext_active);
743             right = _build_node_c(aTHX_ x, nf, ridx, rn, depth + 1, limit,
744             mode_flag, ext_active);
745             result = _mk_oblique(aTHX_ pool, coef, active, b, left, right);
746             free(pool); free(coef);
747             }
748             return result;
749             }
750              
751             void build_forest_xs(SV* x_sv, int n_pts, int n_feats, int n_trees,
752             int psi, int limit, int mode_flag, int ext_level,
753             SV* out_rv) {
754             dTHX;
755             STRLEN tl;
756             const double* x;
757             AV* out;
758             int* all;
759             int t, i;
760              
761             if (!SvROK(out_rv) || SvTYPE(SvRV(out_rv)) != SVt_PVAV) {
762             croak("build_forest_xs: out must be an arrayref");
763             }
764             x = (const double*)SvPVbyte(x_sv, tl);
765             out = (AV*)SvRV(out_rv);
766             av_clear(out);
767             if (n_trees > 0) av_extend(out, n_trees - 1);
768              
769             all = (int*)malloc(n_pts * sizeof(int));
770             for (t = 0; t < n_trees; t++) {
771             int* sample;
772              
773             for (i = 0; i < n_pts; i++) all[i] = i;
774             for (i = 0; i < psi; i++) {
775             int j = i + (int)(Drand01() * (n_pts - i));
776             int tmp = all[i]; all[i] = all[j]; all[j] = tmp;
777             }
778             sample = (int*)malloc(psi * sizeof(int));
779             memcpy(sample, all, psi * sizeof(int));
780              
781             av_store(out, t,
782             _build_node_c(aTHX_ x, n_feats, sample, psi, 0, limit,
783             mode_flag, ext_level));
784             }
785             free(all);
786             }
787              
788             /* ---------------------------------------------------------------------
789             * build_forest_openmp_xs -- OpenMP-parallel fit() tree builder.
790             *
791             * build_forest_xs (above) is bit-identical to the pure-Perl path
792             * because every random draw goes through Drand01(), the same
793             * generator Perl's rand()/srand() use -- but that generator is a
794             * single mutable struct shared by the whole interpreter, so calling
795             * it concurrently from multiple OpenMP threads would be a data race.
796             * The same is true of any Perl API call (newAV, newSViv, ...): Perl's
797             * SV allocator isn't safe to call from multiple OS threads sharing one
798             * interpreter without a lock that would just serialise everything
799             * anyway.
800             *
801             * So this builder trades the bit-identical guarantee for real thread
802             * parallelism: each tree gets its own splitmix64 PRNG stream, seeded
803             * from a tree index (not thread id or scheduling order), so results
804             * are still reproducible for a fixed seed and n_trees regardless of
805             * OMP_NUM_THREADS -- just different from what build_forest_xs or the
806             * pure-Perl path would produce for the same seed. The one Drand01()
807             * call in this function happens before the parallel region starts
808             * (single-threaded), so the result still varies with the model's
809             * `seed` the way every other code path does; it isn't used inside the
810             * parallel loop.
811             *
812             * Each tree is built entirely with plain C data (row-index int arrays,
813             * a growable TreeBuf of packed doubles/ints) -- no Perl API call
814             * happens anywhere inside the parallel region. Each node record in
815             * TreeBuf uses _pack_tree's 6-double SoA layout (see the file-top
816             * comment), but the node ORDER differs: records are appended
817             * post-order (a node is pushed after both its children, since child
818             * indices must be known first), so the root is the last record --
819             * _pack_tree's pre-order puts it at 0. _unpack_forest accounts for
820             * this. Oblique coefficients are also always stored sparse (in the
821             * random pool's order) -- the dense-pack fast path is skipped because
822             * its only purpose is speeding up score_all_xs, and _rebuild_c_trees
823             * reapplies it anyway once the caller unpacks these buffers back into
824             * the standard Perl tree shape and re-derives the scoring buffers.
825             *
826             * After the parallel region, each tree's TreeBuf is copied into a Perl
827             * string SV (one memcpy each, serially) and stored into nodes_rv /
828             * idx_rv / val_rv -- the caller unpacks these into ordinary nested
829             * Perl trees for $self->{trees} (so to_json/persistence/_rebuild_c_trees
830             * are unaffected). ------------------------------------------------ */
831              
832             typedef struct {
833             double *nodes; size_t n_nodes, cap_nodes;
834             int *idx; size_t n_idx, cap_idx;
835             double *val; size_t n_val, cap_val;
836             } TreeBuf;
837              
838             static void tb_init(TreeBuf *b) {
839             b->nodes = NULL; b->n_nodes = 0; b->cap_nodes = 0;
840             b->idx = NULL; b->n_idx = 0; b->cap_idx = 0;
841             b->val = NULL; b->n_val = 0; b->cap_val = 0;
842             }
843              
844             static void tb_free(TreeBuf *b) {
845             free(b->nodes); free(b->idx); free(b->val);
846             }
847              
848             static int tb_push_node(TreeBuf *b, double f0, double f1, double f2,
849             double f3, double f4, double f5) {
850             double *slot;
851             if (b->n_nodes == b->cap_nodes) {
852             size_t newcap = b->cap_nodes ? b->cap_nodes * 2 : 64;
853             b->nodes = (double*)realloc(b->nodes, newcap * 6 * sizeof(double));
854             b->cap_nodes = newcap;
855             }
856             slot = b->nodes + b->n_nodes * 6;
857             slot[0] = f0; slot[1] = f1; slot[2] = f2;
858             slot[3] = f3; slot[4] = f4; slot[5] = f5;
859             return (int)(b->n_nodes++);
860             }
861              
862             /* Appends n (idx[k], val[k]) pairs and returns the offset they start
863             * at -- the `coff` an oblique node record stores. */
864             static int tb_push_coef(TreeBuf *b, const int *idx, const double *val,
865             int n) {
866             int off = (int)b->n_idx;
867             if (b->n_idx + (size_t)n > b->cap_idx) {
868             size_t newcap = b->cap_idx ? b->cap_idx * 2 : 64;
869             if (newcap < b->n_idx + (size_t)n) newcap = b->n_idx + (size_t)n;
870             b->idx = (int*)realloc(b->idx, newcap * sizeof(int));
871             b->cap_idx = newcap;
872             }
873             if (b->n_val + (size_t)n > b->cap_val) {
874             size_t newcap = b->cap_val ? b->cap_val * 2 : 64;
875             if (newcap < b->n_val + (size_t)n) newcap = b->n_val + (size_t)n;
876             b->val = (double*)realloc(b->val, newcap * sizeof(double));
877             b->cap_val = newcap;
878             }
879             memcpy(b->idx + b->n_idx, idx, (size_t)n * sizeof(int));
880             memcpy(b->val + b->n_val, val, (size_t)n * sizeof(double));
881             b->n_idx += n;
882             b->n_val += n;
883             return off;
884             }
885              
886             /* splitmix64 -- fast, well-mixed, and per-stream state fits in one
887             * uint64_t, which is all a thread-private PRNG needs here. Not
888             * cryptographic; doesn't need to be. */
889             static uint64_t sm64_next(uint64_t *s) {
890             uint64_t z = (*s += 0x9E3779B97F4A7C15ULL);
891             z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
892             z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
893             return z ^ (z >> 31);
894             }
895              
896             static double sm64_drand(uint64_t *s) {
897             return (double)(sm64_next(s) >> 11) * (1.0 / 9007199254740992.0);
898             }
899              
900             static double _ts_randn(uint64_t *s) {
901             double u1 = sm64_drand(s);
902             double u2;
903             if (u1 == 0.0) u1 = 1e-12;
904             u2 = sm64_drand(s);
905             return sqrt(-2.0 * log(u1)) * cos(6.283185307179586 * u2);
906             }
907              
908             /* Thread-safe twin of _build_node_c: same split algorithm, but reads
909             * randomness from a thread-private splitmix64 stream instead of
910             * Drand01(), and writes into a TreeBuf instead of allocating Perl AVs
911             * -- so it touches no interpreter-global state and is safe to call
912             * concurrently from an OpenMP parallel region, one tree per thread. */
913             static int _build_node_packed(const double* x, int nf, int* idxs, int size,
914             int depth, int limit, int mode_flag,
915             int ext_active, TreeBuf *buf, uint64_t *rng) {
916             double *lo, *hi;
917             int *varying, nv, f, my_idx;
918              
919             if (depth >= limit || size <= 1) {
920             my_idx = tb_push_node(buf, 0.0, (double)size, 0.0, 0.0, 0.0, 0.0);
921             free(idxs);
922             return my_idx;
923             }
924              
925             lo = (double*)malloc(nf * sizeof(double));
926             hi = (double*)malloc(nf * sizeof(double));
927             for (f = 0; f < nf; f++) {
928             lo[f] = HUGE_VAL;
929             hi[f] = -HUGE_VAL;
930             }
931             for (int i = 0; i < size; i++) {
932             const double* row = x + (size_t)idxs[i] * (size_t)nf;
933             /* See the matching comment in _build_node_c: no isnan() guard
934             * needed, since NaN < x / NaN > x are always false already --
935             * that's what lets this vectorize as a plain min/max scan.
936             * omp simd here is thread-safe to call from inside the caller's
937             * omp parallel region: it's a per-thread vectorization hint,
938             * not a team construct, so it doesn't nest into anything. */
939             #ifdef _OPENMP
940             #pragma omp simd
941             #endif
942             for (int f2 = 0; f2 < nf; f2++) {
943             double v = row[f2];
944             if (v < lo[f2]) lo[f2] = v;
945             if (v > hi[f2]) hi[f2] = v;
946             }
947             }
948              
949             varying = (int*)malloc(nf * sizeof(int));
950             nv = 0;
951             for (f = 0; f < nf; f++) {
952             if (lo[f] < hi[f]) varying[nv++] = f;
953             }
954              
955             if (nv == 0) {
956             free(lo); free(hi); free(varying);
957             my_idx = tb_push_node(buf, 0.0, (double)size, 0.0, 0.0, 0.0, 0.0);
958             free(idxs);
959             return my_idx;
960             }
961              
962             if (mode_flag == 0) {
963             int attr = varying[(int)(sm64_drand(rng) * nv)];
964             double split = lo[attr] + sm64_drand(rng) * (hi[attr] - lo[attr]);
965             int *lidx = (int*)malloc(size * sizeof(int));
966             int *ridx = (int*)malloc(size * sizeof(int));
967             int ln = 0, rn = 0, i, li, ri;
968              
969             for (i = 0; i < size; i++) {
970             int row = idxs[i];
971             double v = x[(size_t)row * (size_t)nf + attr];
972             if (v < split) lidx[ln++] = row; else ridx[rn++] = row;
973             }
974             free(idxs); free(lo); free(hi); free(varying);
975              
976             li = _build_node_packed(x, nf, lidx, ln, depth + 1, limit,
977             mode_flag, ext_active, buf, rng);
978             ri = _build_node_packed(x, nf, ridx, rn, depth + 1, limit,
979             mode_flag, ext_active, buf, rng);
980             my_idx = tb_push_node(buf, 1.0, (double)attr, split,
981             (double)li, (double)ri, 0.0);
982             } else {
983             int active = ext_active + 1;
984             int *pool, *lidx, *ridx;
985             double *coef;
986             double b = 0.0;
987             int ln = 0, rn = 0, i, k, li, ri, coff;
988              
989             if (active > nv) active = nv;
990             pool = (int*)malloc(nv * sizeof(int));
991             memcpy(pool, varying, nv * sizeof(int));
992             for (i = 0; i < active; i++) {
993             int j = i + (int)(sm64_drand(rng) * (nv - i));
994             int tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
995             }
996              
997             coef = (double*)malloc(active * sizeof(double));
998             for (k = 0; k < active; k++) {
999             int ff = pool[k];
1000             double c = _ts_randn(rng);
1001             double p = lo[ff] + sm64_drand(rng) * (hi[ff] - lo[ff]);
1002             coef[k] = c;
1003             b += c * p;
1004             }
1005              
1006             lidx = (int*)malloc(size * sizeof(int));
1007             ridx = (int*)malloc(size * sizeof(int));
1008             for (i = 0; i < size; i++) {
1009             int row = idxs[i];
1010             double dot = 0.0;
1011             for (k = 0; k < active; k++) {
1012             dot += coef[k] * x[(size_t)row * (size_t)nf + pool[k]];
1013             }
1014             if (dot <= b) lidx[ln++] = row; else ridx[rn++] = row;
1015             }
1016             free(idxs); free(lo); free(hi); free(varying);
1017              
1018             li = _build_node_packed(x, nf, lidx, ln, depth + 1, limit,
1019             mode_flag, ext_active, buf, rng);
1020             ri = _build_node_packed(x, nf, ridx, rn, depth + 1, limit,
1021             mode_flag, ext_active, buf, rng);
1022             coff = tb_push_coef(buf, pool, coef, active);
1023             my_idx = tb_push_node(buf, 2.0, (double)coff, (double)active,
1024             (double)li, (double)ri, b);
1025             free(pool); free(coef);
1026             }
1027             return my_idx;
1028             }
1029              
1030             void build_forest_openmp_xs(SV* x_sv, int n_pts, int n_feats, int n_trees,
1031             int psi, int limit, int mode_flag,
1032             int ext_level, SV* nodes_rv, SV* idx_rv,
1033             SV* val_rv, int use_openmp) {
1034             dTHX;
1035             STRLEN tl;
1036             const double* x;
1037             AV *nodes_av, *idx_av, *val_av;
1038             TreeBuf *bufs;
1039             uint64_t base_seed;
1040             int t;
1041              
1042             if (!SvROK(nodes_rv) || SvTYPE(SvRV(nodes_rv)) != SVt_PVAV ||
1043             !SvROK(idx_rv) || SvTYPE(SvRV(idx_rv)) != SVt_PVAV ||
1044             !SvROK(val_rv) || SvTYPE(SvRV(val_rv)) != SVt_PVAV) {
1045             croak("build_forest_openmp_xs: nodes/idx/val must be arrayrefs");
1046             }
1047             x = (const double*)SvPVbyte(x_sv, tl);
1048             nodes_av = (AV*)SvRV(nodes_rv);
1049             idx_av = (AV*)SvRV(idx_rv);
1050             val_av = (AV*)SvRV(val_rv);
1051             av_clear(nodes_av); av_clear(idx_av); av_clear(val_av);
1052             if (n_trees > 0) {
1053             av_extend(nodes_av, n_trees - 1);
1054             av_extend(idx_av, n_trees - 1);
1055             av_extend(val_av, n_trees - 1);
1056             }
1057              
1058             /* Single Drand01() call, before the parallel region starts, so it's
1059             * still a plain serial call into the interpreter's RNG state. */
1060             base_seed = (uint64_t)(Drand01() * 18446744073709551615.0);
1061              
1062             bufs = (TreeBuf*)malloc((size_t)n_trees * sizeof(TreeBuf));
1063             for (t = 0; t < n_trees; t++) tb_init(&bufs[t]);
1064              
1065             #ifdef _OPENMP
1066             #pragma omp parallel for schedule(dynamic) if(use_openmp)
1067             #endif
1068             for (int t = 0; t < n_trees; t++) {
1069             /* Seeded from the tree index, not thread id or iteration order,
1070             * so the mapping from tree -> RNG stream is independent of
1071             * OMP_NUM_THREADS / scheduling. sm64_next() mixes once more so
1072             * adjacent tree indices (which differ by one golden-ratio step)
1073             * don't start from too-similar states. */
1074             uint64_t rng = base_seed + (uint64_t)t * 0x9E3779B97F4A7C15ULL;
1075             rng = sm64_next(&rng);
1076             int *all = (int*)malloc((size_t)n_pts * sizeof(int));
1077             int *sample;
1078             int i;
1079              
1080             for (i = 0; i < n_pts; i++) all[i] = i;
1081             for (i = 0; i < psi; i++) {
1082             int j = i + (int)(sm64_drand(&rng) * (n_pts - i));
1083             int tmp = all[i]; all[i] = all[j]; all[j] = tmp;
1084             }
1085             sample = (int*)malloc((size_t)psi * sizeof(int));
1086             memcpy(sample, all, (size_t)psi * sizeof(int));
1087             free(all);
1088              
1089             _build_node_packed(x, n_feats, sample, psi, 0, limit, mode_flag,
1090             ext_level, &bufs[t], &rng);
1091             }
1092              
1093             for (t = 0; t < n_trees; t++) {
1094             /* newSVpvn(NULL, 0) makes an undef SV, not an empty-string one --
1095             * axis-mode trees never call tb_push_coef, so idx/val stay NULL.
1096             * Pass "" instead so the Perl side's unpack('...', $sv) always
1097             * gets a defined (if empty) string, never undef. */
1098             av_store(nodes_av, t, newSVpvn((char*)bufs[t].nodes,
1099             bufs[t].n_nodes * 6 * sizeof(double)));
1100             av_store(idx_av, t, bufs[t].n_idx
1101             ? newSVpvn((char*)bufs[t].idx, bufs[t].n_idx * sizeof(int))
1102             : newSVpvn("", 0));
1103             av_store(val_av, t, bufs[t].n_val
1104             ? newSVpvn((char*)bufs[t].val, bufs[t].n_val * sizeof(double))
1105             : newSVpvn("", 0));
1106             tb_free(&bufs[t]);
1107             }
1108             free(bufs);
1109             }
1110              
1111             /* ---------------------------------------------------------------------
1112             * impute_fill_xs(data_sv, n_pts, n_feats, how, out_rv)
1113             *
1114             * C replacement for _compute_impute_fill's Perl loop: walks the raw
1115             * arrayref-of-arrayrefs directly (like pack_input_xs), collecting each
1116             * feature's present (defined) values, then reduces them to one fill
1117             * value per feature -- mean (how == 0) or median (how == 1) -- and
1118             * writes n_feats doubles into out_rv.
1119             *
1120             * Values are collected in row order (i = 0..n_pts-1), the same order
1121             * the Perl version's `grep { defined } map { $_->[$f] } @data` walks
1122             * them in, so the mean's left-to-right summation lands on the exact
1123             * same float as the Perl path -- use_c toggles speed here, not the
1124             * computed fill, matching the rest of the module.
1125             *
1126             * The median is an exact order statistic (not summation-dependent), so
1127             * it matches the Perl path's sort-based median by definition regardless
1128             * of which selection algorithm finds it. Croaks with the same message
1129             * as the Perl fallback if a feature has no present values anywhere in
1130             * the dataset. */
1131             typedef struct { double *v; size_t n, cap; } DVec;
1132              
1133             static void dvec_push(DVec *d, double x) {
1134             if (d->n == d->cap) {
1135             size_t newcap = d->cap ? d->cap * 2 : 64;
1136             d->v = (double*)realloc(d->v, newcap * sizeof(double));
1137             d->cap = newcap;
1138             }
1139             d->v[d->n++] = x;
1140             }
1141              
1142             static void _dswap(double *a, double *b) { double t = *a; *a = *b; *b = t; }
1143              
1144             /* Lomuto partition with a median-of-three pivot (avoids the O(n^2)
1145             * worst case a fixed pivot hits on already-sorted or reverse-sorted
1146             * input, which real feature columns -- timestamps, counters -- often
1147             * are). Returns the pivot's final index. */
1148             static int _partition_lomuto(double *a, int lo, int hi) {
1149             int mid = lo + (hi - lo) / 2;
1150             double pivot;
1151             int i, j;
1152             if (a[mid] < a[lo]) _dswap(&a[lo], &a[mid]);
1153             if (a[hi] < a[lo]) _dswap(&a[lo], &a[hi]);
1154             if (a[hi] < a[mid]) _dswap(&a[mid], &a[hi]);
1155             _dswap(&a[mid], &a[hi]);
1156             pivot = a[hi];
1157             i = lo;
1158             for (j = lo; j < hi; j++) {
1159             if (a[j] < pivot) { _dswap(&a[i], &a[j]); i++; }
1160             }
1161             _dswap(&a[i], &a[hi]);
1162             return i;
1163             }
1164              
1165             /* Quickselect: returns the k-th smallest (0-indexed) of a[0..n-1],
1166             * reordering a[] in the process (fine -- it's a private scratch copy).
1167             * O(n) average case vs. a full O(n log n) sort. */
1168             static double _kth_smallest(double *a, int n, int k) {
1169             int lo = 0, hi = n - 1;
1170             while (lo < hi) {
1171             int p = _partition_lomuto(a, lo, hi);
1172             if (p == k) return a[p];
1173             if (p < k) lo = p + 1; else hi = p - 1;
1174             }
1175             return a[lo];
1176             }
1177              
1178             /* Median of a[0..n-1] (reorders a[]). Odd n: the single middle order
1179             * statistic. Even n: quickselect finds the lower-median at k = n/2-1,
1180             * which leaves every a[i > k] >= a[k] (the standard quickselect
1181             * post-condition) -- so the upper-median is just the min of that
1182             * remaining slice, one more linear scan instead of a second full
1183             * selection pass. */
1184             static double _median_select(double *a, int n) {
1185             if (n % 2 == 1) {
1186             return _kth_smallest(a, n, n / 2);
1187             } else {
1188             int k = n / 2 - 1;
1189             double lower = _kth_smallest(a, n, k);
1190             double upper = a[k + 1];
1191             int i;
1192             for (i = k + 2; i < n; i++) {
1193             if (a[i] < upper) upper = a[i];
1194             }
1195             return (lower + upper) / 2.0;
1196             }
1197             }
1198              
1199             void impute_fill_xs(SV* data_sv, int n_pts, int n_feats, int how,
1200             SV* out_rv) {
1201             dTHX;
1202             AV *outer, *out;
1203             DVec *cols;
1204             int i, f;
1205              
1206             if (!SvROK(data_sv) || SvTYPE(SvRV(data_sv)) != SVt_PVAV) {
1207             croak("impute_fill_xs: data must be an arrayref");
1208             }
1209             if (!SvROK(out_rv) || SvTYPE(SvRV(out_rv)) != SVt_PVAV) {
1210             croak("impute_fill_xs: out must be an arrayref");
1211             }
1212             outer = (AV*)SvRV(data_sv);
1213             out = (AV*)SvRV(out_rv);
1214              
1215             cols = (DVec*)calloc((size_t)n_feats, sizeof(DVec));
1216              
1217             for (i = 0; i < n_pts; i++) {
1218             SV** row_pp = av_fetch(outer, i, 0);
1219             AV* row;
1220             if (!row_pp || !*row_pp || !SvROK(*row_pp) ||
1221             SvTYPE(SvRV(*row_pp)) != SVt_PVAV) {
1222             continue;
1223             }
1224             row = (AV*)SvRV(*row_pp);
1225             for (f = 0; f < n_feats; f++) {
1226             SV** v = av_fetch(row, f, 0);
1227             if (v && *v && SvOK(*v)) {
1228             dvec_push(&cols[f], SvNV(*v));
1229             }
1230             }
1231             }
1232              
1233             /* Validate every column before freeing anything: croak() longjmps
1234             * out of this function, so any cleanup loop reachable after a
1235             * partial computation has already started (and already freed some
1236             * cols[i].v) risks a double free on those same pointers. Checking
1237             * all columns up front, before the computation loop below frees
1238             * anything, avoids that entirely. Matches the Perl fallback's
1239             * behaviour of reporting the first empty column in feature order. */
1240             for (f = 0; f < n_feats; f++) {
1241             if (cols[f].n == 0) {
1242             int col = f;
1243             for (i = 0; i < n_feats; i++) free(cols[i].v);
1244             free(cols);
1245             croak("impute: feature column %d has no present values", col);
1246             }
1247             }
1248              
1249             av_clear(out);
1250             if (n_feats > 0) av_extend(out, n_feats - 1);
1251              
1252             for (f = 0; f < n_feats; f++) {
1253             double result;
1254             if (how == 0) {
1255             double sum = 0.0;
1256             for (i = 0; i < (int)cols[f].n; i++) sum += cols[f].v[i];
1257             result = sum / (double)cols[f].n;
1258             } else {
1259             result = _median_select(cols[f].v, (int)cols[f].n);
1260             }
1261             av_store(out, f, newSVnv(result));
1262             free(cols[f].v);
1263             }
1264             free(cols);
1265             }
1266             __INLINE_C__
1267              
1268             # IF_NO_C=1 skips even attempting to set up the C backend -- useful for
1269             # forcing the pure-Perl path without touching every constructor call
1270             # (use_c => 0), e.g. to get a clean timing baseline or to avoid the
1271             # compile attempt's overhead/noise in a container known to lack a
1272             # compiler. Everything below is skipped and $HAS_C stays 0.
1273             unless ( $ENV{IF_NO_C} ) {
1274              
1275             # Defaults recorded when `perl Makefile.PL` ran. Makefile.PL generates
1276             # Algorithm::Classifier::IsolationForest::BuildFlags, capturing the
1277             # IF_* values active at configure time plus whether a prebuilt object
1278             # was scheduled for install (see "Compile at install time" in the POD
1279             # below). From a plain source checkout the generated file is absent,
1280             # the hard defaults here apply, and no prebuilt object is looked for.
1281             my ( $def_opt, $def_arch, $def_no_omp, $prebuilt ) = ( '-O3', '', 0, 0 );
1282             {
1283             local $@;
1284             my $rec = eval {
1285             require Algorithm::Classifier::IsolationForest::BuildFlags;
1286             Algorithm::Classifier::IsolationForest::BuildFlags::flags();
1287             };
1288             if ( ref $rec eq 'HASH' ) {
1289             $def_opt = $rec->{opt} if defined $rec->{opt};
1290             $def_arch = $rec->{arch} if defined $rec->{arch};
1291             $def_no_omp = $rec->{no_openmp} ? 1 : 0;
1292             $prebuilt = $rec->{prebuilt} ? 1 : 0;
1293             }
1294             }
1295              
1296             # -O3 is the usual default: it's safe to enable unconditionally and
1297             # matters here -- the extended-mode oblique dot product is wrapped in
1298             # `#pragma omp simd`, but without aggressive optimization the compiler
1299             # may still emit scalar code. Use OPTIMIZE (not CCFLAGS) -- CCFLAGS is
1300             # prepended to the cc line and would be shadowed by Perl's own `-O2 -g`
1301             # that ExtUtils::MakeMaker appends afterward (last `-O` wins in gcc).
1302             # IF_OPT overrides the level itself (e.g. IF_OPT=-O2 to work around a
1303             # miscompile, or to shorten build time while developing); it's
1304             # validated against a fixed set of GCC/Clang -O flags rather than
1305             # interpolated as-is, since this string eventually reaches a shell
1306             # command line via ExtUtils::MakeMaker.
1307             my $opt = $def_opt;
1308             if ( defined $ENV{IF_OPT} ) {
1309             if ( $ENV{IF_OPT} =~ /\A-O[0123sgz]\z/ ) {
1310             $opt = $ENV{IF_OPT};
1311             } else {
1312             warn "Algorithm::Classifier::IsolationForest: ignoring invalid "
1313             . "IF_OPT value '$ENV{IF_OPT}' (expected one of -O0 -O1 -O2 "
1314             . "-O3 -Os -Og -Oz); using $opt\n";
1315             }
1316             }
1317              
1318             # -march= lets the compiler target specific instruction-set
1319             # extensions (AVX2 gather + FMA, etc.) for the oblique dot product
1320             # and the fit-time min/max scan's `#pragma omp simd` loops.
1321             #
1322             # IF_ARCH= sets it explicitly (e.g. "x86-64-v3", "skylake",
1323             # "znver3") -- validated against a conservative identifier charset
1324             # since, like IF_OPT, it flows into a compiler command line.
1325             # IF_NATIVE=1 remains as shorthand for IF_ARCH=native and is used
1326             # when IF_ARCH isn't set. Prefer a specific IF_ARCH value over
1327             # IF_NATIVE on a machine you don't control exclusively: blanket
1328             # -march=native pulls in whatever the build host has, including
1329             # AVX-512 on some Intel CPUs, which is known to trigger clock
1330             # throttling under sustained heavy use and can make throughput
1331             # *worse* than a conservative target like x86-64-v3 (AVX2, no
1332             # AVX-512). Either way, the cached artefact under _Inline/ is then
1333             # pinned to that instruction set, so leave both unset if the
1334             # directory is shared across machines with different CPUs.
1335             my $arch = $def_arch;
1336             if ( defined $ENV{IF_ARCH} ) {
1337             if ( $ENV{IF_ARCH} eq '' or $ENV{IF_ARCH} eq 'none' ) {
1338              
1339             # Explicit opt-out: overrides an arch recorded at configure
1340             # time (there is no other way to request a plain build on
1341             # an install configured with IF_ARCH).
1342             $arch = '';
1343             } elsif ( $ENV{IF_ARCH} =~ /\A[A-Za-z0-9_.+=-]+\z/ ) {
1344             $arch = $ENV{IF_ARCH};
1345             } else {
1346             warn "Algorithm::Classifier::IsolationForest: ignoring invalid " . "IF_ARCH value '$ENV{IF_ARCH}'\n";
1347             }
1348             } elsif ( $ENV{IF_NATIVE} ) {
1349             $arch = 'native';
1350             }
1351             # -ffp-contract=off rides along with any -march: once the target
1352             # has FMA (x86-64-v3, most -march=native hosts), the compiler may
1353             # otherwise contract a*b+c expressions into fused multiply-adds
1354             # whose different rounding breaks the documented guarantee that
1355             # use_c => 1 and use_c => 0 build bit-identical trees (one ulp in a
1356             # split value cascades into a structurally different tree). The
1357             # -march speedup comes from AVX2 vectorization, not contraction,
1358             # so this costs little (verified against the fit-determinism and
1359             # scoring-parity tests).
1360             my $opt_level = $opt;
1361             $opt_level .= " -march=$arch -ffp-contract=off" if length $arch;
1362              
1363             # IF_NO_OPENMP=1 forces the serial C build: the OpenMP compile attempt
1364             # is skipped, so the object has no libgomp linkage and never starts an
1365             # OpenMP runtime in the process. Distinct from OMP_NUM_THREADS=1,
1366             # which runs the parallel code on a single thread but still loads
1367             # libgomp. An explicit IF_NO_OPENMP=0 re-enables OpenMP over a
1368             # no-openmp configure-time default.
1369             my $no_omp
1370             = defined $ENV{IF_NO_OPENMP}
1371             ? ( $ENV{IF_NO_OPENMP} ? 1 : 0 )
1372             : $def_no_omp;
1373              
1374             # The prebuilt object is only trusted when the effective flags match
1375             # what it was compiled with; any difference -- or an explicit
1376             # IF_RUNTIME_BUILD=1 -- falls through to the classic runtime Inline::C
1377             # build below, which honours the requested flags via the MD5-keyed
1378             # _Inline/ cache exactly as before prebuilt support existed.
1379             # IF_INSTALL_BUILD is the `make` rule driving the install-time compile
1380             # (see Makefile.PL); it must never short-circuit into loading an
1381             # older object.
1382             my $use_prebuilt
1383             = $prebuilt
1384             && !$ENV{IF_RUNTIME_BUILD}
1385             && !$ENV{IF_INSTALL_BUILD}
1386             && $opt eq $def_opt
1387             && $arch eq $def_arch
1388             && $no_omp == $def_no_omp;
1389              
1390             # Inline::C hashes the C source to decide whether to rebuild but
1391             # does NOT include CCFLAGS / OPTIMIZE in that hash. Without the
1392             # tag below, toggling IF_NATIVE/IF_ARCH/IF_OPT (or editing the
1393             # optimisation flags here) would silently reuse a cached binary
1394             # built with stale flags. Embedding the active flags as a leading
1395             # comment forces the hash to differ when they change. The OpenMP
1396             # and serial builds get distinct tags so they cache to separate
1397             # artefacts.
1398             my $omp_tag = "/* if_build: openmp $opt_level */\n";
1399             my $serial_tag = "/* if_build: serial $opt_level */\n";
1400              
1401             if ( $ENV{IF_INSTALL_BUILD} ) {
1402              
1403             # `make` is driving: the rule Makefile.PL appended runs this load
1404             # with IF_INSTALL_BUILD=1 and @ARGV = (version, INST_ARCHLIB),
1405             # which is where Inline's install mode reads them from. _INSTALL_
1406             # makes Inline compile the backend and place the shared object
1407             # under blib/arch so `make install` ships it; NAME/VERSION give
1408             # the object a fixed identity XSLoader can find at run time
1409             # (Inline's install mode also requires both and checks VERSION
1410             # against $ARGV[0]). Same OpenMP-then-serial fallback as the
1411             # runtime build below.
1412             my @install = (
1413             NAME => __PACKAGE__,
1414             VERSION => $VERSION,
1415             _INSTALL_ => 1,
1416             );
1417             unless ($no_omp) {
1418             local $@;
1419             eval {
1420             require Inline;
1421             Inline->import(
1422             C => $omp_tag . $C_CODE,
1423             CCFLAGS => '-fopenmp',
1424             OPTIMIZE => $opt_level,
1425             LIBS => '-lm -lgomp',
1426             @install,
1427             );
1428             $HAS_C = 1;
1429             };
1430             } ## end unless ($no_omp)
1431             unless ($HAS_C) {
1432             local $@;
1433             eval {
1434             require Inline;
1435             Inline->import(
1436             C => $serial_tag . $C_CODE,
1437             OPTIMIZE => $opt_level,
1438             LIBS => '-lm',
1439             @install,
1440             );
1441             $HAS_C = 1;
1442             };
1443             } ## end unless ($HAS_C)
1444             $C_SOURCE = 'prebuilt' if $HAS_C;
1445             } else {
1446              
1447             # Fast path: the object compiled at `make` time was installed
1448             # under auto/ like any XS module, so plain XSLoader digs it out of
1449             # @INC with no Inline involvement -- no compiler, no _Inline/
1450             # directory, and a few ms instead of a first-run compile. Any
1451             # failure (object deleted, different perl, version mismatch after
1452             # an upgrade, libgomp since removed) just falls through to the
1453             # runtime build.
1454             if ($use_prebuilt) {
1455             local $@;
1456             eval {
1457             require XSLoader;
1458             XSLoader::load( __PACKAGE__, $VERSION );
1459             $HAS_C = 1;
1460             $C_SOURCE = 'prebuilt';
1461             };
1462             }
1463              
1464             # Classic runtime Inline::C build, MD5-cached under _Inline/.
1465             # Reached when there is no matching prebuilt object: a source
1466             # checkout, IF_RUNTIME_BUILD=1, or IF_* values differing from the
1467             # ones recorded at configure time. Try compiling with OpenMP
1468             # first; on any failure (compiler doesn't accept -fopenmp, libgomp
1469             # missing, etc.) fall back to a serial build.
1470             unless ( $HAS_C or $no_omp ) {
1471             local $@;
1472             eval {
1473             require Inline;
1474             Inline->import(
1475             C => $omp_tag . $C_CODE,
1476             CCFLAGS => '-fopenmp',
1477             OPTIMIZE => $opt_level,
1478             LIBS => '-lm -lgomp',
1479             );
1480             $HAS_C = 1;
1481             $C_SOURCE = 'runtime';
1482             };
1483             } ## end unless ( $HAS_C or $no_omp )
1484             unless ($HAS_C) {
1485             local $@;
1486             eval {
1487             require Inline;
1488             Inline->import(
1489             C => $serial_tag . $C_CODE,
1490             OPTIMIZE => $opt_level,
1491             LIBS => '-lm',
1492             );
1493             $HAS_C = 1;
1494             $C_SOURCE = 'runtime';
1495             };
1496             } ## end unless ($HAS_C)
1497             } ## end else [ if ( $ENV{IF_INSTALL_BUILD} ) ]
1498             $OPT_LEVEL = $opt_level if $HAS_C;
1499              
1500             } ## end unless ( $ENV{IF_NO_C} )
1501             $HAS_OPENMP = ( $HAS_C && defined &has_openmp_xs && has_openmp_xs() ) ? 1 : 0;
1502             $HAS_SIMD = ( $HAS_C && defined &has_simd_xs && has_simd_xs() ) ? 1 : 0;
1503             }
1504              
1505             =encoding UTF-8
1506              
1507             =head1 NAME
1508              
1509             Algorithm::Classifier::IsolationForest - unsupervised anomaly detection via Isolation Forest or Extended Isolation Forest
1510              
1511             =head1 SYNOPSIS
1512              
1513             use Algorithm::Classifier::IsolationForest;
1514              
1515             my @data = ([0.1, -0.2], [0.0, 0.1], [5.0, 6.0], ...);
1516              
1517             # Classic, axis-parallel Isolation Forest
1518             my $iforest = Algorithm::Classifier::IsolationForest->new(
1519             n_trees => 100,
1520             sample_size => 256,
1521             seed => 42,
1522             );
1523             $iforest->fit(\@data);
1524              
1525             my $scores = $iforest->score_samples(\@data); # arrayref, each in (0,1]
1526             my $flags = $iforest->predict(\@data, 0.6); # arrayref of 0/1
1527              
1528             # Save and reload
1529             $iforest->save('model.json');
1530             my $reloaded = Algorithm::Classifier::IsolationForest->load('model.json');
1531              
1532             # Extended Isolation Forest (oblique hyperplane splits)
1533             my $eif = Algorithm::Classifier::IsolationForest->new(
1534             mode => 'extended',
1535             seed => 42,
1536             );
1537             $eif->fit(\@data);
1538              
1539             # Parallel training (fork-based, Unix-like platforms): build the
1540             # n_trees across several worker processes.
1541             my $iforest = Algorithm::Classifier::IsolationForest->new(
1542             n_trees => 200,
1543             sample_size => 256,
1544             seed => 42,
1545             parallel_fit => 4, # 4 forked workers
1546             );
1547             $iforest->fit(\@data);
1548              
1549             # Pre-pack a dataset to skip the per-call input-walk cost when the
1550             # same data gets scored many times (interactive tuning, dashboards).
1551             my $packed = $iforest->pack_data(\@data);
1552             my $scores = $iforest->score_samples($packed);
1553             my $flags = $iforest->predict($packed, 0.6);
1554              
1555             # Get scores and labels as two flat arrayrefs in one call -- cheaper
1556             # than score_predict_samples when you don't need the paired shape.
1557             my ($s, $l) = $iforest->score_predict_split(\@data, 0.6);
1558              
1559             =head1 DESCRIPTION
1560              
1561             Isolation Forest (Liu, Fei Tony & Ting, Kai & Zhou, Zhi-Hua, 2008) detects anomalies by random
1562             partitioning rather than by modelling normal points. Each tree repeatedly
1563             splits the data. Points that get isolated after only a few splits are likely
1564             anomalies. The score is the average isolation depth across many trees,
1565             normalised so values approach 1 for anomalies and stay below 0.5 for normal
1566             points.
1567              
1568             In extended mode the module implements the Extended Isolation Forest
1569             variant. Each split is a random hyperplane instead of an axis-aligned cut,
1570             which removes the rectangular, axis-aligned bias in the score field and
1571             tends to help on elongated or multi-modal data.
1572              
1573             psi referenced below is ψ or the pitchfork math symbol referenced in the paper,
1574             Liu, Fei Tony & Ting, Kai & Zhou, Zhi-Hua. (2008). Isolation Forest. 413 - 422. 10.1109/ICDM.2008.17.
1575              
1576             ... or max samples.
1577              
1578             L
1579              
1580             =head1 NATIVE ACCELERATION (Inline::C and OpenMP)
1581              
1582             Both the scoring hot path (C, C, C,
1583             C, and C) and the C
1584             tree builder are automatically accelerated through
1585             L when it is installed and a working C compiler is reachable.
1586             If the toolchain also accepts C<-fopenmp> and can link against
1587             C, the per-point tree walk runs in parallel across all
1588             available CPU cores using OpenMP, and the extended-mode oblique dot
1589             product is vectorised via C<#pragma omp simd> -- which on modern x86
1590             compilers translates to an unrolled FMA / AVX gather chain that's
1591             substantially faster for high-feature-count extended models.
1592              
1593             C's tree builder (subsampling plus the recursive axis/oblique
1594             split search) runs in C the same way when C is on, replacing the
1595             per-node Perl arrayref copying with plain int-array partitioning --
1596             typically an order of magnitude faster, and dramatically more so at
1597             higher feature counts where the pure-Perl per-cell loop dominates. Its
1598             random draws go through the same generator C/C use
1599             internally, in the same call order the pure-Perl builder uses, so a
1600             given C produces bit-identical trees whether C is on or
1601             off -- switching backends changes only how fast the model is built, not
1602             the model itself.
1603              
1604             By default this C builder is single-threaded per call, because Perl's
1605             RNG state isn't safe to share across OpenMP threads. Two ways to scale
1606             fit() across cores are available (see below for why they don't compose):
1607              
1608             =over 4
1609              
1610             =item * C forks N worker processes, each building its
1611             share of the trees with the (still single-threaded) C builder. Fixed
1612             IPC/serialisation overhead per worker means this can cost more than it
1613             saves once a fit already completes in milliseconds; it's most useful
1614             once a single-process fit is large enough that the fork/Storable
1615             overhead is small relative to the work being split.
1616              
1617             =item * C builds trees across OpenMP threads within a
1618             single process (one tree per thread), using a separate, thread-safe
1619             PRNG seeded per tree index instead of Perl's C. This means
1620             trees built with C are I bit-identical to the
1621             default C path for the same seed -- but a fixed seed and
1622             C still reproduce the same trees regardless of
1623             C or how OpenMP schedules the work. It's off by
1624             default (unlike C/C, which only ever change speed,
1625             this changes which trees get built) and only takes effect when C
1626             is also on and OpenMP is linked in.
1627              
1628             =back
1629              
1630             These two do NOT compose, despite both existing to parallelise fit().
1631             A process that has run any OpenMP region -- including plain
1632             C/C with the default C -- and
1633             then Cs (as C does) hands each child a copy of
1634             libgomp's thread pool whose worker threads did not survive the fork. A
1635             child that then starts its own C<#pragma omp parallel> region (as
1636             C would) tries to reuse that now-invalid pool and
1637             hangs. This is a general limitation of combining C with OpenMP,
1638             not something fixable from Perl, so C's forked workers
1639             always use the single-threaded C builder regardless of
1640             C -- setting both just means C wins and
1641             C has no effect for that call.
1642              
1643             Detection happens once when the module is loaded. When the
1644             distribution was installed with C available, the C backend
1645             was already compiled during C and the installed object is loaded
1646             directly (see L below);
1647             otherwise the backend is compiled on first load and the artefact is
1648             cached under C<_Inline/> and reused on subsequent runs. Five package
1649             variables report what the load picked up:
1650              
1651             $Algorithm::Classifier::IsolationForest::HAS_C # 0/1
1652             $Algorithm::Classifier::IsolationForest::HAS_OPENMP # 0/1
1653             $Algorithm::Classifier::IsolationForest::HAS_SIMD # 0/1 (OpenMP 4.0+)
1654             $Algorithm::Classifier::IsolationForest::OPT_LEVEL # e.g. "-O3 -march=native", '' if HAS_C is 0
1655             $Algorithm::Classifier::IsolationForest::C_SOURCE # 'prebuilt' / 'runtime', '' if HAS_C is 0
1656              
1657             Neither dependency is required. Without C the module falls
1658             back to a pure-Perl implementation that produces identical results, just
1659             slower; without OpenMP the C backend runs single-threaded.
1660              
1661             The bundled C subcommand performs a tiny fit + score and
1662             prints which backend is active (including the build flags below), which
1663             is the recommended way to verify the build picked up the optional
1664             dependencies on a given machine.
1665              
1666             =head2 Compile at install time (the prebuilt object)
1667              
1668             When C is usable while the distribution itself is being
1669             built, C arranges for the C backend to be compiled
1670             once during C and installed alongside the module like any XS
1671             object. At run time that object is loaded directly through
1672             L: no C compiler, no C modules, and no C<_Inline/>
1673             cache directory are needed on the machine the module ends up running
1674             on, and the first-load compile pause disappears entirely.
1675              
1676             On x86-64 hardware from roughly the last decade,
1677             C is a reasonable configure line:
1678             it bakes AVX2 + FMA (without AVX-512) into the prebuilt object, which
1679             can speed up extended-mode scoring (how much is hardware-dependent --
1680             benchmark with C before assuming) while avoiding the
1681             C<-march=native> caveats described under L.
1682             Bit-for-bit result parity with the pure-Perl backend is preserved
1683             either way (see C below).
1684              
1685             The C build flags described below are captured when
1686             C runs -- set them in the environment of I
1687             command, not of C -- and recorded in the generated
1688             C module, which
1689             thereby also fixes what the prebuilt object was compiled with. At run
1690             time the recorded values serve as the defaults, so a process started
1691             with no C variables set uses the prebuilt object as-is.
1692              
1693             Setting C variables at run time keeps working exactly as in
1694             releases without prebuilt support: if the requested flags differ from
1695             the recorded ones, the prebuilt object (compiled with the wrong flags
1696             for the request) is skipped and the module compiles at first load into
1697             C<_Inline/> -- which does need C and a compiler on that
1698             machine. Two related knobs exist:
1699              
1700             =over 4
1701              
1702             =item * C -- ignore the prebuilt object
1703             unconditionally and compile at first load even though the requested
1704             flags match the recorded ones. Useful when the installed object is
1705             suspect (built on a different CPU than it now runs on, linked against a
1706             libgomp that has since changed) or to A/B a fresh local build against
1707             the shipped one.
1708              
1709             =item * C -- internal; set by the generated
1710             Makefile rule that performs the install-time compile. Not meant for
1711             manual use.
1712              
1713             =back
1714              
1715             If the prebuilt object cannot be loaded for any reason (deleted, built
1716             against a different perl, version mismatch after an upgrade), the
1717             module quietly falls through the same chain as always: runtime
1718             Inline::C build first, pure Perl last.
1719              
1720             =head2 Tuning the C build
1721              
1722             These environment variables are read once, the first time the module is
1723             loaded, so they must be set before that -- e.g. in the shell before
1724             running a script, not via C<%ENV> inside the script itself. They are
1725             also read by C to pick the flags baked into the
1726             prebuilt object (see above); at run time they override the recorded
1727             configure-time values, at the price of a runtime compile.
1728              
1729             =over 4
1730              
1731             =item * C -- skip attempting to build the C backend entirely.
1732             Equivalent to constructing every instance with C 0>, but
1733             without needing to touch every call site; useful for a clean pure-Perl
1734             timing baseline, or to avoid the compile attempt's overhead/noise on a
1735             host known to lack a C compiler (the attempt already fails gracefully
1736             without this, so it's a convenience, not a correctness fix).
1737              
1738             =item * C (or C<-O0>/C<-O1>/C<-Os>/C<-Og>/C<-Oz>) -- override
1739             the default C<-O3>, e.g. to shorten build time while iterating, or work
1740             around a miscompile on an unusual toolchain. Invalid values are ignored
1741             with a warning rather than passed through, since this string reaches a
1742             compiler command line.
1743              
1744             =item * CvalueE> -- adds C<-march=EvalueE> so the
1745             compiler can target specific instruction-set extensions (AVX2 gather +
1746             FMA, etc.) for the extended-mode oblique dot product and the fit-time
1747             min/max scan's C<#pragma omp simd> loops. Accepts values like
1748             C, C, or C -- whatever your compiler's
1749             C<-march=> accepts. Also validated (a restricted character set, not
1750             passed through as-is) for the same reason as C. The special
1751             value C (or an empty string) opts out of any arch recorded at
1752             configure time, yielding a plain build. Whenever a C<-march> is in
1753             effect the build also adds C<-ffp-contract=off>: with FMA available
1754             the compiler would otherwise contract C into fused
1755             multiply-adds whose different rounding breaks the guarantee that
1756             C 1> and C 0> build bit-identical trees (the
1757             C<-march> speedup comes from vectorization, not contraction, so this
1758             costs essentially nothing).
1759              
1760             =item * C -- shorthand for C; ignored if
1761             C is also set. Prefer a specific C value over this on
1762             a machine you don't control exclusively (a shared build host, a
1763             container base image): blanket C<-march=native> pulls in whatever
1764             instruction sets the build host happens to have, including AVX-512 on
1765             some Intel CPUs -- which is known to trigger clock throttling under
1766             sustained heavy use and can make throughput I than a
1767             conservative target like C (AVX2, no AVX-512). If in doubt,
1768             benchmark both before committing to one.
1769              
1770             =item * C -- build (or select) the serial C backend: the
1771             OpenMP compile attempt is skipped entirely, so the resulting object has
1772             no libgomp linkage and never starts an OpenMP runtime inside the
1773             process. This differs from C, which merely runs the
1774             parallel code on one thread but still loads libgomp. Set at
1775             C time it yields a serial prebuilt object; set at run
1776             time against an OpenMP prebuilt install it triggers a runtime serial
1777             build (needing a compiler). An explicit C re-enables
1778             OpenMP over a serial configure-time default.
1779              
1780             =back
1781              
1782             Whichever of these are used, the cached artefact under C<_Inline/> is
1783             pinned to that build's instruction set -- delete C<_Inline/> (or use a
1784             separate one per host) if the directory is shared across machines with
1785             different CPUs, or a stale binary built for a narrower instruction set
1786             than the current host will simply keep being reused.
1787              
1788             =head2 Tuning the OpenMP runtime
1789              
1790             These are standard OpenMP environment variables libgomp already reads
1791             at run time (set before running your script, no module-specific
1792             handling needed) -- listed here because they matter most for exactly
1793             the workloads this module has: C's per-point parallel
1794             loop and C's per-tree parallel loop.
1795              
1796             =over 4
1797              
1798             =item * C -- caps how many threads a parallel region
1799             uses. Useful to leave headroom for other work sharing the machine, or
1800             to pin down C reproducibility checks (see its docs
1801             above: results don't depend on this, but it's a natural thing to vary
1802             when confirming that).
1803              
1804             =item * C / C -- on multi-socket
1805             or otherwise NUMA machines, pins each thread to a core near where its
1806             data already lives instead of letting the OS scheduler migrate threads
1807             across sockets mid-run. Both C (each thread scans its own
1808             slice of the packed query buffer) and C (each thread
1809             builds one tree from packed training data) benefit from this when the
1810             input is large enough to not fit comfortably in one socket's cache.
1811              
1812             =back
1813              
1814             These cost nothing to try -- unlike C/C, they're
1815             read fresh every run, not baked into a cached binary, so there's no
1816             downside to experimenting per invocation.
1817              
1818             =head1 GENERAL METHODS
1819              
1820             =head2 new(%args)
1821              
1822             Inits the object.
1823              
1824             - n_trees :: number of isolation trees in the ensemble
1825             default :: 100
1826              
1827             - sample_size :: sub-sample size used to build each tree... max samples
1828             default :: 256
1829              
1830             - max_depth :: per-tree height limit... if not defined is set to ceil(log2(psi))
1831             default :: undef
1832              
1833             - seed :: optional integer to seed srand with for reproducible trees...
1834             see perldoc -f srand for more info. This number is processed via abs(int()).
1835             default :: undef
1836              
1837             - mode :: if it should be IF or EIF
1838             axis :: classic axis-parallel splits (IF)
1839             extended :: oblique hyperplane splits (EIF)
1840             default :: axis
1841              
1842             - extension_level :: extended mode only... how many features take partin each
1843             split. 0 behaves like a single-feature (axis) cut; the
1844             maximum (n_features - 1) uses every varying feature. undef
1845             => maximum. Clamped to [0, n_features - 1] at fit time.
1846              
1847             - contamination :: expected fraction of anomalies, in (0, 0.5]. When given,
1848             fit() learns a score threshold that flags this fraction of
1849             the training set, and predict() uses it by default. undef
1850             => no learned threshold (predict() falls back to 0.5).
1851             default :: undef
1852              
1853             - missing :: how fit() treats undef (missing) feature cells. Scoring always
1854             tolerates undef regardless of this setting; it governs fit().
1855             die :: croak from fit() if the training data contains any
1856             undef cell. Scoring still maps undef to 0 (the
1857             long-standing behaviour), so a model fitted on clean
1858             data can still score rows with missing features.
1859             zero :: treat a missing cell as the value 0, at fit and score.
1860             impute :: replace a missing cell with the per-feature mean (or
1861             median, see impute_with) learned from the present
1862             values at fit time. The fill vector is stored on the
1863             model and reused for scoring and persistence.
1864             nan :: build feature ranges from present values only and route
1865             a point missing the split feature to the right child,
1866             consistently at fit and score time. Missingness is
1867             preserved as signal rather than filled.
1868             default :: die
1869              
1870             - impute_with :: 'mean' or 'median'; the statistic used to compute the
1871             per-feature fill under missing => 'impute'. Ignored otherwise.
1872             default :: mean
1873              
1874             - parallel_fit :: positive integer N => build the trees across N forked
1875             worker processes during fit(). Each worker gets a derived seed
1876             (parent seed + worker_id * 1009) so the parallel fit is
1877             reproducible across runs at fixed worker count -- but the trees
1878             produced are NOT bit-identical to a serial fit with the same
1879             seed, because the RNG draws happen in a different order.
1880             Inference is unaffected. Falls back silently to serial on
1881             platforms without a real fork() (e.g. Windows without Cygwin).
1882             default :: undef (serial)
1883              
1884             - use_c :: boolean, override whether the Inline::C backend is used for
1885             both scoring and fit()'s tree builder. When false the instance
1886             falls back to pure Perl for both even if the C backend compiled
1887             successfully. When true (or unset) the C backend is used if
1888             available ($HAS_C). fit() with use_c on produces bit-identical
1889             trees to use_c off for the same seed -- only build speed differs.
1890             default :: $HAS_C
1891              
1892             - use_openmp :: boolean, override whether OpenMP parallel scoring is
1893             used inside score_all_xs(). When false the C tree walk runs
1894             single-threaded even if OpenMP was linked in. Ignored when
1895             use_c is false (pure Perl has no OpenMP path).
1896             default :: $HAS_OPENMP
1897              
1898             - use_openmp_fit :: boolean, build fit()'s trees across OpenMP threads
1899             (one tree per thread) instead of the single-threaded C builder.
1900             Opt-in and off by default: unlike use_c/use_openmp, this changes
1901             which trees get built. Perl's RNG isn't safe to call from
1902             multiple OS threads sharing one interpreter, so this path seeds
1903             an independent PRNG per tree from the tree index rather than
1904             Drand01() -- trees differ from the use_c (single-threaded)
1905             and pure-Perl paths even with the same seed, though a fixed
1906             seed and n_trees still reproduce the same trees regardless of
1907             OMP_NUM_THREADS or scheduling. Does NOT compose with
1908             parallel_fit: a forked child starting its own OpenMP region
1909             after the parent process has used OpenMP for anything can
1910             hang (a general fork()+libgomp limitation), so parallel_fit's
1911             workers always use the single-threaded C builder regardless
1912             of this setting -- setting both just means parallel_fit wins.
1913             Ignored (clamped to 0) when use_c is false or OpenMP isn't
1914             linked in.
1915             default :: 0
1916              
1917             Note: log2 under Perl is as below...
1918              
1919             log($psi) / log(2)
1920              
1921             =cut
1922              
1923             sub new {
1924 164     164 1 6042501 my ( $class, %args ) = @_;
1925              
1926 164   100     1010 my $mode = $args{mode} // 'axis';
1927 164 100 100     904 croak "mode must be 'axis' or 'extended'"
1928             unless $mode eq 'axis' || $mode eq 'extended';
1929              
1930             # How fit() treats undef (missing) feature cells. Scoring always
1931             # tolerates undef regardless of this setting -- it governs fit only.
1932             # die :: croak if the training data contains any undef cell (default)
1933             # zero :: treat a missing cell as the value 0
1934             # impute :: replace a missing cell with the per-feature mean/median
1935             # learned from the present values at fit time
1936             # nan :: build ranges over present values only and route a point
1937             # missing the split feature consistently to one branch, at
1938             # both fit and score time
1939 163   100     653 my $missing = $args{missing} // 'die';
1940 163 100       1373 croak "missing must be one of: die, zero, impute, nan"
1941             unless $missing =~ /\A(?:die|zero|impute|nan)\z/;
1942              
1943 162   100     570 my $impute_with = $args{impute_with} // 'mean';
1944 162 100       804 croak "impute_with must be 'mean' or 'median'"
1945             unless $impute_with =~ /\A(?:mean|median)\z/;
1946              
1947 161 100       457 if ( defined( $args{seed} ) ) {
1948 131         352 $args{seed} = abs( int( $args{seed} ) );
1949             }
1950              
1951             # Clamp the accel knobs against what the build actually has. Passing
1952             # use_c => 1 on a machine where Inline::C never compiled would otherwise
1953             # leave score_samples() calling an undefined XS sub at first use.
1954             # OpenMP is meaningless without the C tree walk, so force it off
1955             # whenever the C backend is off -- matches the documented
1956             # "Ignored when use_c is false" semantics.
1957             my $use_c
1958             = defined $args{use_c}
1959 161 100 100     762 ? ( $args{use_c} && $HAS_C ? 1 : 0 )
    100          
1960             : $HAS_C;
1961             my $use_openmp
1962             = defined $args{use_openmp}
1963 161 100 100     390 ? ( $args{use_openmp} && $HAS_OPENMP ? 1 : 0 )
    100          
1964             : $HAS_OPENMP;
1965 161 100       347 $use_openmp = 0 unless $use_c;
1966              
1967             # Opt-in only (default 0, not $HAS_OPENMP): this path changes which
1968             # trees fit() builds (see docs above), unlike use_c/use_openmp which
1969             # only change speed. Clamped the same way use_openmp is.
1970 161 100 33     592 my $use_openmp_fit = ( $args{use_openmp_fit} && $HAS_OPENMP && $use_c ) ? 1 : 0;
1971              
1972             my $self = {
1973             n_trees => $args{n_trees} // 100,
1974             sample_size => $args{sample_size} // 256,
1975             max_depth => $args{max_depth}, # undef => auto
1976             seed => $args{seed}, # undef => non-deterministic
1977             mode => $mode,
1978             extension_level => $args{extension_level}, # undef => max, resolved in fit()
1979             contamination => $args{contamination}, # undef => no learned threshold
1980             parallel_fit => $args{parallel_fit}, # undef/0/1 => serial; N>1 => fork
1981             missing => $missing, # die|zero|impute|nan
1982             impute_with => $impute_with, # mean|median (impute mode only)
1983             missing_fill => undef, # per-feature fill, learned in fit() if impute
1984             _use_c => $use_c,
1985             _use_openmp => $use_openmp,
1986             _use_openmp_fit => $use_openmp_fit,
1987             threshold => undef, # learned in fit() if contamination set
1988             trees => [],
1989             c_psi => undef, # c(psi), set during fit()
1990             n_features => undef,
1991             feature_names => $args{feature_names}, # optional arrayref of per-feature labels
1992 161   100     2213 };
      100        
1993              
1994 161 100       621 croak "n_trees must be >= 1" unless $self->{n_trees} >= 1;
1995 160 100       539 croak "sample_size must be >= 1" unless $self->{sample_size} >= 1;
1996             croak "extension_level must be >= 0"
1997 159 100 100     559 if defined $self->{extension_level} && $self->{extension_level} < 0;
1998             croak "contamination must be a number in (0, 0.5]"
1999             if defined $self->{contamination}
2000 158 100 100     805 && !( $self->{contamination} > 0 && $self->{contamination} <= 0.5 );
      100        
2001             croak "parallel_fit must be a positive integer"
2002             if defined $self->{parallel_fit}
2003 155 100 66     846 && ( $self->{parallel_fit} !~ /^\d+$/ || $self->{parallel_fit} < 1 );
      66        
2004              
2005 153         670 return bless $self, $class;
2006             } ## end sub new
2007              
2008             =head2 decision_threshold
2009              
2010             The score cutoff C uses by default; undef unless C was
2011             set.
2012              
2013             =cut
2014              
2015 6     6 1 3065 sub decision_threshold { return $_[0]->{threshold} }
2016              
2017             =head2 feature_names
2018              
2019             Returns the arrayref of feature name strings stored with the model, or undef
2020             if none were provided at fit time.
2021              
2022             my $names = $iforest->feature_names;
2023              
2024             =cut
2025              
2026 0     0 1 0 sub feature_names { return $_[0]->{feature_names} }
2027              
2028             =head2 fit
2029              
2030             Trains the model on the specified data.
2031              
2032             The data taken is an array of arrays. Each sub-array is one sample and must
2033             contain one or more numeric features. All samples must have the same number
2034             of features. There is no upper limit on dimensionality.
2035              
2036             @training_data = (
2037             [ 3, 5 ],
2038             [ 2.3, 1 ],
2039             [ 5, 9 ],
2040             ...
2041             );
2042              
2043             # Three-feature example
2044             @training_data = (
2045             [ 1.0, 2.0, 3.0 ],
2046             [ 1.1, 1.9, 3.1 ],
2047             ...
2048             );
2049              
2050             Below shows an example of building a gaussian cluster and using that for training.
2051              
2052             # so it is reproducible
2053             srand(7);
2054              
2055             # build a gaussian cluster and add a handful of outliers...
2056              
2057             use constant PI => 3.14159265358979;
2058             sub gaussian {
2059             my ($mu, $sigma) = @_;
2060             my $u1 = rand() || 1e-12;
2061             my $u2 = rand();
2062             my $z = sqrt(-2 * log($u1)) * cos(2 * PI * $u2);
2063             return $mu + $sigma * $z;
2064             }
2065              
2066             # add some normal items
2067             for (1 .. 500) {
2068             push @data, [ gaussian(0, 1), gaussian(0, 1) ];
2069             push @truth, 0;
2070             }
2071             # add some outliers
2072             for (1 .. 20) {
2073             my $angle = rand() * 2 * PI;
2074             my $radius = 5 + rand() * 3; # distance 5..8 from the origin
2075             push @data, [ $radius * cos($angle), $radius * sin($angle) ];
2076             push @truth, 1;
2077             }
2078              
2079             $iforest->fit(\@data);
2080              
2081             =cut
2082              
2083             sub fit {
2084 136     136 1 8261 my ( $self, $data ) = @_;
2085              
2086 136 100 100     1435 croak "fit() expects a non-empty arrayref of samples"
2087             unless ref $data eq 'ARRAY' && @$data;
2088             croak "each sample must be an arrayref of features"
2089 130 100 100     660 unless ref $data->[0] eq 'ARRAY' && @{ $data->[0] };
  128         675  
2090              
2091 126         216 my $n_features = scalar @{ $data->[0] };
  126         241  
2092 126         428 $self->{n_features} = $n_features;
2093              
2094             # Apply the missing-value strategy before any tree is built. Depending
2095             # on the strategy this either croaks (die), returns a dense copy with
2096             # undef cells filled (zero/impute), or passes the data through with
2097             # undef preserved for the split logic to route (nan). Everything below
2098             # trains on $train, never the raw $data.
2099 126         603 my $train = $self->_prepare_fit_data($data);
2100              
2101 120         248 my $n = scalar @$train;
2102              
2103             # The sub-sample cannot be larger than the data set itself.
2104 120         595 my $psi = min( $self->{sample_size}, $n );
2105 120         437 $self->{c_psi} = _c($psi);
2106 120         304 $self->{psi_used} = $psi;
2107              
2108             # Resolve the extension level against the data's dimensionality.
2109 120 100       340 if ( $self->{mode} eq 'extended' ) {
2110 29         55 my $max_ext = $n_features - 1;
2111             my $ext
2112             = defined $self->{extension_level}
2113             ? $self->{extension_level}
2114 29 100       104 : $max_ext;
2115 29 50       82 $ext = 0 if $ext < 0;
2116 29 100       72 $ext = $max_ext if $ext > $max_ext;
2117 29         63 $self->{extension_level_used} = $ext;
2118             } else {
2119 91         182 $self->{extension_level_used} = undef;
2120             }
2121              
2122             # Height limit: the average tree height ceil(log2(psi)). Past this depth the
2123             # remaining points are scored using the c(size) adjustment instead.
2124             my $limit
2125             = defined $self->{max_depth}
2126             ? $self->{max_depth}
2127 120 50       749 : ceil( log($psi) / log(2) );
2128 120 50       339 $limit = 1 if $limit < 1;
2129 120         362 $self->{max_depth_used} = $limit;
2130              
2131 120 50       509 srand( $self->{seed} ) if defined $self->{seed};
2132              
2133 120         258 my $workers = $self->{parallel_fit};
2134 120 100 100     962 if ( defined $workers
    100 66        
    100 66        
      66        
2135             && $workers > 1
2136             && $self->{n_trees} > 1
2137             && _fork_supported() )
2138             {
2139 8         45 $self->{trees} = $self->_fit_trees_parallel( $train, $psi, $limit, $workers );
2140             } elsif ( $self->{_use_c} && $self->{_use_openmp_fit} ) {
2141 7         39 $self->{trees} = $self->_build_forest_openmp( $train, $psi, $limit, $self->{n_trees} );
2142             } elsif ( $self->{_use_c} ) {
2143             $self->{trees}
2144 58         335 = $self->_build_forest_c( $train, $psi, $limit, $self->{n_trees} );
2145             } else {
2146 47         75 my @trees;
2147 47         129 for ( 1 .. $self->{n_trees} ) {
2148 2169         5401 my $sample = _subsample( $train, $psi );
2149 2169         5658 push @trees, $self->_build_tree( $sample, 0, $limit );
2150             }
2151 47         204 $self->{trees} = \@trees;
2152             }
2153              
2154             # On a re-fit, packed scoring buffers from the previous fit are still
2155             # sitting on the object; score_samples() below would pick them up and
2156             # learn the contamination threshold against the OLD forest. Drop them
2157             # so the training-set scoring runs pure-Perl against the trees just
2158             # built; _rebuild_c_trees repacks from the new trees at the end.
2159 120         701 delete @$self{qw(_c_nodes _c_coef_idx _c_coef_val)};
2160              
2161             # If a contamination rate was requested, learn the score cutoff that flags
2162             # that fraction of the training set. We place the threshold midway between
2163             # the k-th and (k+1)-th highest training scores, so it sits in the gap
2164             # between flagged and unflagged points -- unambiguous and robust to the
2165             # tiny float rounding introduced by JSON serialisation.
2166 120 100       508 if ( defined $self->{contamination} ) {
2167 3         15 my $scores = $self->score_samples($train);
2168 3         45 my @desc = sort { $b <=> $a } @$scores;
  3979         4366  
2169 3         11 my $n_pts = scalar @desc;
2170 3         19 my $k = int( $self->{contamination} * $n_pts + 0.5 );
2171 3 50       12 $k = 1 if $k < 1;
2172 3 50       8 $k = $n_pts if $k > $n_pts;
2173 3 50       44 $self->{threshold} = $k < $n_pts
2174             ? ( $desc[ $k - 1 ] + $desc[$k] ) / 2.0 # midpoint of the boundary
2175             : $desc[ $n_pts - 1 ] - 1e-9; # k == n: flag everything
2176             } ## end if ( defined $self->{contamination} )
2177              
2178 120 100       641 $self->_rebuild_c_trees() if $self->{_use_c};
2179 120         1289 return $self;
2180             } ## end sub fit
2181              
2182             =head2 pack_data(\@data)
2183              
2184             Returns an opaque, blessed wrapper around the input dataset that the
2185             scoring methods can use directly, skipping the per-call work of walking
2186             the arrayref-of-arrayrefs and converting each cell into a double. At
2187             high feature counts this is a meaningful win when the same dataset is
2188             scored repeatedly (e.g. interactive threshold tuning, dashboards,
2189             plotting that updates as parameters change).
2190              
2191             Requires the Inline::C backend; croaks if C is false.
2192              
2193             my $packed = $forest->pack_data(\@data);
2194              
2195             # Now any of these accept either an arrayref or the packed wrapper:
2196             my $scores = $forest->score_samples($packed);
2197             my $flags = $forest->predict($packed, 0.6);
2198             my ($s, $l) = $forest->score_predict_split($packed);
2199              
2200             The wrapper has C and C accessors for introspection.
2201             The feature count is matched against the model on every call; passing a
2202             packed dataset built for a different feature count is a fatal error.
2203              
2204             =cut
2205              
2206             =head2 path_lengths(\@data)
2207              
2208             Returns an arrayref of the mean isolation depth per sample, for inspection.
2209              
2210             my $lengths = $forest->path_lengths(\@data);
2211              
2212             print "x, y, length\n";
2213              
2214             my $int=0;
2215             while (defined($data[$int])) {
2216             print $data[$int][0].', '.$data[$int][1].', '.$lengths->[$int]."\n";
2217              
2218             $int++;
2219             }
2220              
2221             =cut
2222              
2223             sub path_lengths {
2224 7508     7508 1 26841 my ( $self, $data ) = @_;
2225 7508         18372 $self->_check_fitted;
2226 7506         12994 my $trees = $self->{trees};
2227 7506         12122 my $t = scalar @$trees;
2228              
2229 7506 50 66     24274 if ( $self->{_use_c} && $self->{_c_nodes} ) {
2230 7505         15053 my ( $n_pts, $nf, $x_packed ) = $self->_resolve_input($data);
2231 7505         14388 my $sums_packed = "\0" x ( $n_pts * 8 );
2232             score_all_xs(
2233             $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
2234             $x_packed, $sums_packed, $n_pts,
2235             $nf, $t, $self->{_use_openmp}
2236 7505         164785 );
2237 7505         13170 my $result = [];
2238 7505         25925 finalize_path_lengths_xs( $sums_packed, $n_pts, $t + 0.0, $result );
2239 7505         28220 return $result;
2240             } ## end if ( $self->{_use_c} && $self->{_c_nodes} )
2241              
2242 1         4 $data = $self->_prepare_perl_input($data);
2243 1 50       4 my $nan = $self->{missing} eq 'nan' ? 1 : 0;
2244              
2245             # Pure-Perl fallback (tree-outer, sample-inner for cache locality).
2246 1         5 my @sums = (0) x @$data;
2247 1         2 for my $tree (@$trees) {
2248 60         87 for my $i ( 0 .. $#$data ) {
2249 6000         7615 $sums[$i] += _path_length( $data->[$i], $tree, 0, $nan );
2250             }
2251             }
2252 1         10 return [ map { $_ / $t } @sums ];
  100         131  
2253             } ## end sub path_lengths
2254              
2255             =head2 predict(\@data, $threshold)
2256              
2257             Returns an arrayref of 0/1 labels for the specified data.
2258              
2259             If threshold is not specified it uses the contamination-learned cutoff (if
2260             C was called with C), otherwise 0.5.
2261              
2262             my $results = $forest->predict(\@data, $threshold);
2263              
2264             print "x, y, result\n";
2265              
2266             my $int=0;
2267             while (defined($data[$int])) {
2268             print $data[$int][0].', '.$data[$int][1].', '.$results->[$int]."\n";
2269              
2270             $int++;
2271             }
2272              
2273             =cut
2274              
2275             sub predict {
2276 15007     15007 1 88887 my ( $self, $data, $threshold ) = @_;
2277             $threshold
2278             = defined $threshold ? $threshold
2279             : defined $self->{threshold} ? $self->{threshold}
2280 15007 100       26414 : 0.5;
    100          
2281 15007         34432 $self->_check_fitted;
2282              
2283             # Fast path: threshold the raw path-length sums directly, skipping the
2284             # per-point exp() and the intermediate scores arrayref.
2285             # Derivation: score = exp(-sum * log(2) / (c*t))
2286             # so score >= T iff sum <= -log(T) * c * t / log(2)
2287             # Only valid for a normal threshold in (0, 1) and a positive c.
2288 15005 100 66     90964 if ( $self->{_use_c}
      33        
      66        
      100        
2289             && $self->{_c_nodes}
2290             && $self->{c_psi} > 0
2291             && $threshold > 0
2292             && $threshold < 1 )
2293             {
2294 14988         24443 my $trees = $self->{trees};
2295 14988         22557 my $t = scalar @$trees;
2296 14988         23349 my $c = $self->{c_psi};
2297 14988         29168 my ( $n_pts, $nf, $x_packed ) = $self->_resolve_input($data);
2298 14988         28142 my $sums_packed = "\0" x ( $n_pts * 8 );
2299             score_all_xs(
2300             $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
2301             $x_packed, $sums_packed, $n_pts,
2302             $nf, $t, $self->{_use_openmp}
2303 14988         378510 );
2304 14988         35356 my $sum_threshold = -log($threshold) * $c * $t / log(2);
2305 14988         23134 my $result = [];
2306 14988         46407 predict_sums_xs( $sums_packed, $n_pts, $sum_threshold, $result );
2307 14988         57411 return $result;
2308             } ## end if ( $self->{_use_c} && $self->{_c_nodes} ...)
2309              
2310             # Fallback: edge thresholds, c==0, or no C backend.
2311 17         92 my $scores = $self->score_samples( $self->_to_arrayref($data) );
2312 17 100       65 return [ map { $_ >= $threshold ? 1 : 0 } @$scores ];
  1285         2359  
2313             } ## end sub predict
2314              
2315             =head2 predict_tagged(\%row, $threshold)
2316              
2317             Predicts whether a single sample is an anomaly using a hashref of named
2318             feature values. The model must have been fitted (or loaded from a model
2319             that was fitted) with feature names stored via C.
2320              
2321             C<$threshold> defaults the same way as in C.
2322              
2323             Returns a scalar 1 (anomaly) or 0 (normal).
2324              
2325             my $label = $forest->predict_tagged(
2326             { cpu => 0.9, mem => 0.4, disk => 0.1 },
2327             );
2328              
2329             Croaks if the model has no stored feature names, if the hashref contains a
2330             key that is not a known feature name, or if a feature name is absent from the
2331             hashref.
2332              
2333             =cut
2334              
2335             =head2 tagged_row_to_array(\%row, $caller)
2336              
2337             Validates a hashref of named feature values against the model's stored
2338             C and returns a positional arrayref ready to pass to any
2339             of the scoring or prediction methods.
2340              
2341             C<$caller> is a string used in error messages to identify which method
2342             triggered the validation (pass the calling method's name).
2343              
2344             my $vec = $forest->tagged_row_to_array(\%row, 'my_method');
2345             # returns e.g. [0.9, 0.4, 0.1] ordered by feature_names
2346              
2347             Croaks if:
2348              
2349             =over 4
2350              
2351             =item * C<$row> is not a hashref
2352              
2353             =item * the model has no stored C
2354              
2355             =item * the hashref contains a key that is not a known feature name
2356              
2357             =item * a feature name is absent from the hashref
2358              
2359             =back
2360              
2361             =cut
2362              
2363             sub tagged_row_to_array {
2364 0     0 1 0 my ( $self, $row, $caller ) = @_;
2365 0 0       0 croak "$caller requires a hashref"
2366             unless ref $row eq 'HASH';
2367             croak "this model has no stored feature_names; " . "refit with -t tags or pass feature_names to new()"
2368             unless defined $self->{feature_names}
2369             && ref $self->{feature_names} eq 'ARRAY'
2370 0 0 0     0 && @{ $self->{feature_names} };
  0   0     0  
2371              
2372 0         0 my @names = @{ $self->{feature_names} };
  0         0  
2373              
2374             my @unknown = grep {
2375 0         0 my $k = $_;
  0         0  
2376 0         0 !grep { $_ eq $k } @names
  0         0  
2377             } keys %$row;
2378 0 0       0 croak "unknown feature name(s) in hashref: " . join( ', ', sort @unknown )
2379             if @unknown;
2380              
2381 0         0 my @missing = grep { !exists $row->{$_} } @names;
  0         0  
2382 0 0       0 croak "missing feature name(s) in hashref: " . join( ', ', @missing )
2383             if @missing;
2384              
2385 0         0 return [ map { $row->{$_} } @names ];
  0         0  
2386             } ## end sub tagged_row_to_array
2387              
2388             sub predict_tagged {
2389 0     0 1 0 my ( $self, $row, $threshold ) = @_;
2390 0         0 my $vec = $self->tagged_row_to_array( $row, 'predict_tagged' );
2391 0         0 my $result = $self->predict( [$vec], $threshold );
2392 0         0 return $result->[0];
2393             }
2394              
2395             =head2 score_samples(\@data)
2396              
2397             Returns an arrayref of anomaly scores, between 0 and 1.
2398              
2399             Scores near 1 are strong anomalies (isolated quickly).
2400              
2401             Scores well below 0.5 are normal.
2402              
2403             Scores ~0.5 means the points are hard to tell apart.
2404              
2405             my $scores = $forest->score_samples(\@data);
2406              
2407             print "x, y, score\n";
2408              
2409             my $int=0;
2410             while (defined($data[$int])) {
2411             print $data[$int][0].', '.$data[$int][1].', '.$scores->[$int]."\n";
2412              
2413             $int++;
2414             }
2415              
2416             =cut
2417              
2418             sub score_samples {
2419 14268     14268 1 91845 my ( $self, $data ) = @_;
2420 14268         34835 $self->_check_fitted;
2421 14266         25532 my $c = $self->{c_psi};
2422 14266         23197 my $trees = $self->{trees};
2423 14266         23513 my $t = scalar @$trees;
2424              
2425 14266 100 100     46534 if ( $self->{_use_c} && $self->{_c_nodes} ) {
2426 14208         30270 my ( $n_pts, $nf, $x_packed ) = $self->_resolve_input($data);
2427 14207         28089 my $sums_packed = "\0" x ( $n_pts * 8 );
2428             score_all_xs(
2429             $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
2430             $x_packed, $sums_packed, $n_pts,
2431             $nf, $t, $self->{_use_openmp}
2432 14207         526233 );
2433 14207 50       36206 if ( $c > 0 ) {
2434 14207         28613 my $inv = log(2) / ( $c * $t );
2435 14207         22266 my $result = [];
2436 14207         48518 finalize_scores_xs( $sums_packed, $n_pts, $inv, $result );
2437 14207         57659 return $result;
2438             }
2439 0         0 return [ (0.5) x $n_pts ];
2440             } ## end if ( $self->{_use_c} && $self->{_c_nodes} )
2441              
2442 58         308 $data = $self->_prepare_perl_input($data);
2443 58 100       184 my $nan = $self->{missing} eq 'nan' ? 1 : 0;
2444              
2445             # Pure-Perl fallback (tree-outer, sample-inner for cache locality).
2446 58         381 my @sums = (0) x @$data;
2447 58         153 for my $tree (@$trees) {
2448 4302         7577 for my $i ( 0 .. $#$data ) {
2449 334276         451522 $sums[$i] += _path_length( $data->[$i], $tree, 0, $nan );
2450             }
2451             }
2452              
2453             # Precompute the single normalising factor; exp() is a direct FPU
2454             # instruction and faster than Perl's general-purpose 2**x (pow).
2455             # Derivation: 2**(-avg/c) = 2**(-(sum/t)/c) = exp(-sum * log(2)/(c*t))
2456 58 50       247 if ( $c > 0 ) {
2457 58         125 my $inv = log(2) / ( $c * $t );
2458 58         239 return [ map { exp( -$_ * $inv ) } @sums ];
  4532         6931  
2459             }
2460 0         0 return [ (0.5) x @sums ];
2461             } ## end sub score_samples
2462              
2463             =head2 score_sample_tagged(\%row)
2464              
2465             Scores a single sample supplied as a hashref of named feature values.
2466             The model must have stored feature names (set via C in
2467             C or the C<-t> CLI flag at fit time).
2468              
2469             Returns a scalar anomaly score in (0, 1].
2470              
2471             my $score = $forest->score_sample_tagged({ cpu => 0.9, mem => 0.4 });
2472              
2473             Croaks if the model has no stored feature names, if the hashref contains a
2474             key that is not a known feature name, or if a feature name is absent from the
2475             hashref.
2476              
2477             =cut
2478              
2479             sub score_sample_tagged {
2480 0     0 1 0 my ( $self, $row ) = @_;
2481 0         0 my $vec = $self->tagged_row_to_array( $row, 'score_sample_tagged' );
2482 0         0 my $result = $self->score_samples( [$vec] );
2483 0         0 return $result->[0];
2484             }
2485              
2486             =head2 score_predict_samples
2487              
2488             Returns an array ref of arrays. First value of each sub array is the score with the second being
2489             0/1 for if it is a anomaly or not.
2490              
2491             C<$threshold> defaults the same way as in C.
2492              
2493             my $results = $forest->score_predict_samples(\@data, $threshold);
2494              
2495             print "x, y, score, result\n";
2496              
2497             my $int=0;
2498             while (defined($data[$int])) {
2499             print $data[$int][0].', '.$data[$int][1].', '.$results->[$int][0].', '.$results->[$int][1]."\n";
2500              
2501             $int++;
2502             }
2503              
2504             =cut
2505              
2506             sub score_predict_samples {
2507 5105     5105 1 19114 my ( $self, $data, $threshold ) = @_;
2508             $threshold
2509             = defined $threshold ? $threshold
2510             : defined $self->{threshold} ? $self->{threshold}
2511 5105 50       9636 : 0.5;
    100          
2512 5105         12620 $self->_check_fitted;
2513              
2514             # Fast path: build [score, label] pairs straight from the sum buffer
2515             # in one C call. Avoids the intermediate scores arrayref + Perl
2516             # foreach that allocates ~3*n_pts SVs. Gated identically to predict()
2517             # so the threshold conversion is valid.
2518 5105 50 66     33720 if ( $self->{_use_c}
      33        
      33        
      33        
2519             && $self->{_c_nodes}
2520             && $self->{c_psi} > 0
2521             && $threshold > 0
2522             && $threshold < 1 )
2523             {
2524 5103         8528 my $trees = $self->{trees};
2525 5103         8457 my $t = scalar @$trees;
2526 5103         8591 my $c = $self->{c_psi};
2527 5103         10707 my ( $n_pts, $nf, $x_packed ) = $self->_resolve_input($data);
2528 5103         9862 my $sums_packed = "\0" x ( $n_pts * 8 );
2529             score_all_xs(
2530             $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
2531             $x_packed, $sums_packed, $n_pts,
2532             $nf, $t, $self->{_use_openmp}
2533 5103         246834 );
2534 5103         10993 my $inv = log(2) / ( $c * $t );
2535 5103         9792 my $sum_threshold = -log($threshold) * $c * $t / log(2);
2536 5103         8136 my $result = [];
2537 5103         20630 score_predict_xs( $sums_packed, $n_pts, $inv, $sum_threshold, $result );
2538 5103         23586 return $result;
2539             } ## end if ( $self->{_use_c} && $self->{_c_nodes} ...)
2540              
2541             # Fallback: edge thresholds, c==0, or no C backend.
2542 2         10 my $scores = $self->score_samples( $self->_to_arrayref($data) );
2543              
2544 2         7 my @to_return;
2545 2         4 foreach my $score ( @{$scores} ) {
  2         5  
2546 12 100       19 if ( $score >= $threshold ) {
2547 4         10 push @to_return, [ $score, 1 ];
2548             } else {
2549 8         16 push @to_return, [ $score, 0 ];
2550             }
2551             }
2552              
2553 2         13 return \@to_return;
2554             } ## end sub score_predict_samples
2555              
2556             =head2 score_predict_sample_tagged(\%row, $threshold)
2557              
2558             Scores and classifies a single sample supplied as a hashref of named
2559             feature values. The model must have stored feature names.
2560              
2561             C<$threshold> defaults the same way as in C.
2562              
2563             Returns a two-element arrayref C<[$score, $label]>, matching the per-row
2564             shape that C returns for each row.
2565              
2566             my $pair = $forest->score_predict_sample_tagged({ cpu => 0.9, mem => 0.4 });
2567             printf "score %.4f anomaly %d\n", $pair->[0], $pair->[1];
2568              
2569             Croaks if the model has no stored feature names, if the hashref contains a
2570             key that is not a known feature name, or if a feature name is absent from the
2571             hashref.
2572              
2573             =cut
2574              
2575             sub score_predict_sample_tagged {
2576 0     0 1 0 my ( $self, $row, $threshold ) = @_;
2577 0         0 my $vec = $self->tagged_row_to_array( $row, 'score_predict_sample_tagged' );
2578 0         0 my $result = $self->score_predict_samples( [$vec], $threshold );
2579 0         0 return $result->[0];
2580             }
2581              
2582             =head2 score_predict_split(\@data, $threshold)
2583              
2584             Same data as L but returned as two flat arrayrefs
2585             instead of an arrayref-of-pairs. Allocates roughly half as many Perl
2586             SVs per point (no inner AV, no RV per row), so it is meaningfully faster
2587             when both scores and labels are wanted but the paired shape is not.
2588              
2589             In list context returns C<($scores_aref, $labels_aref)>.
2590              
2591             my ($scores, $labels) = $forest->score_predict_split(\@data);
2592              
2593             for my $i (0 .. $#$scores) {
2594             printf "%s -> score %.4f, label %d\n",
2595             join(',', @{ $data[$i] }), $scores->[$i], $labels->[$i];
2596             }
2597              
2598             C<$threshold> defaults to the contamination-learned cutoff (if C
2599             was called with C) or 0.5.
2600              
2601             =cut
2602              
2603             sub score_predict_split {
2604 14114     14114 1 33772 my ( $self, $data, $threshold ) = @_;
2605             $threshold
2606             = defined $threshold ? $threshold
2607             : defined $self->{threshold} ? $self->{threshold}
2608 14114 50       26694 : 0.5;
    100          
2609 14114         38414 $self->_check_fitted;
2610              
2611             # Fast path: fill two flat arrayrefs (scores + labels) directly from
2612             # the sum buffer in one C call. Skips the inner AV + RV per point
2613             # that score_predict_samples has to allocate.
2614 14114 50 66     91916 if ( $self->{_use_c}
      33        
      33        
      33        
2615             && $self->{_c_nodes}
2616             && $self->{c_psi} > 0
2617             && $threshold > 0
2618             && $threshold < 1 )
2619             {
2620 14112         24722 my $trees = $self->{trees};
2621 14112         22413 my $t = scalar @$trees;
2622 14112         23280 my $c = $self->{c_psi};
2623 14112         28182 my ( $n_pts, $nf, $x_packed ) = $self->_resolve_input($data);
2624 14112         27310 my $sums_packed = "\0" x ( $n_pts * 8 );
2625             score_all_xs(
2626             $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val},
2627             $x_packed, $sums_packed, $n_pts,
2628             $nf, $t, $self->{_use_openmp}
2629 14112         296150 );
2630 14112         30949 my $inv = log(2) / ( $c * $t );
2631 14112         27148 my $sum_threshold = -log($threshold) * $c * $t / log(2);
2632 14112         22452 my $scores = [];
2633 14112         21229 my $labels = [];
2634 14112         52047 score_predict_split_xs( $sums_packed, $n_pts, $inv, $sum_threshold, $scores, $labels );
2635 14112         69231 return ( $scores, $labels );
2636             } ## end if ( $self->{_use_c} && $self->{_c_nodes} ...)
2637              
2638             # Fallback: derive from score_samples.
2639 2         9 my $scores = $self->score_samples( $self->_to_arrayref($data) );
2640 2 100       6 my @labels = map { $_ >= $threshold ? 1 : 0 } @$scores;
  12         26  
2641 2         17 return ( $scores, \@labels );
2642             } ## end sub score_predict_split
2643              
2644             =head1 MODEL SAVE/LOAD METHODS
2645              
2646             =head2 to_json
2647              
2648             Returns a JSON representation of the model.
2649              
2650             Requires fit to have been called.
2651              
2652             my $json = $iforest->to_json;
2653              
2654             =cut
2655              
2656             sub to_json {
2657 16     16 1 165923 my ($self) = @_;
2658 16         99 $self->_check_fitted;
2659             my $payload = {
2660             format => 'Algorithm::Classifier::IsolationForest',
2661             version => 1,
2662             params => {
2663             n_trees => $self->{n_trees},
2664             sample_size => $self->{sample_size},
2665             mode => $self->{mode},
2666             extension_level => $self->{extension_level_used},
2667             contamination => $self->{contamination},
2668             threshold => $self->{threshold},
2669             n_features => $self->{n_features},
2670             psi_used => $self->{psi_used},
2671             c_psi => $self->{c_psi},
2672             max_depth_used => $self->{max_depth_used},
2673             missing => $self->{missing},
2674             impute_with => $self->{impute_with},
2675             missing_fill => $self->{missing_fill},
2676             feature_names => $self->{feature_names},
2677             },
2678             trees => $self->{trees},
2679 14         479 };
2680 14         186 return JSON::PP->new->canonical(1)->encode($payload);
2681             } ## end sub to_json
2682              
2683             =head2 from_json($json)
2684              
2685             Init the object from the model in the specified JSON string.
2686              
2687             my $iforest = Algorithm::Classifier::IsolationForest->from_json($json);
2688              
2689             =cut
2690              
2691             sub from_json {
2692 22     22 1 3928761 my ( $class, $text ) = @_;
2693 22         196 my $payload = JSON::PP->new->decode($text);
2694             croak "not an IsolationForest model"
2695             unless ref $payload eq 'HASH'
2696             && defined $payload->{format}
2697 22 100 100     14848859 && $payload->{format} eq 'Algorithm::Classifier::IsolationForest';
      66        
2698              
2699 20   100     140 my $p = $payload->{params} || {};
2700              
2701             # version 0 used hash-based nodes; version 1+ uses array-based nodes.
2702             # Convert old models on load so the rest of the code only sees arrays.
2703 20   50     179 my $trees = $payload->{trees} || [];
2704 20 100 100     171 if ( ( $payload->{version} // 0 ) < 1 ) {
2705 1         3 $trees = [ map { _hash_node_to_array($_) } @$trees ];
  0         0  
2706             }
2707              
2708             my $self = {
2709             n_trees => $p->{n_trees},
2710             sample_size => $p->{sample_size},
2711             max_depth => undef,
2712             seed => undef,
2713             mode => $p->{mode} // 'axis',
2714             extension_level => $p->{extension_level},
2715             extension_level_used => $p->{extension_level},
2716             contamination => $p->{contamination},
2717             threshold => $p->{threshold},
2718             n_features => $p->{n_features},
2719             psi_used => $p->{psi_used},
2720             c_psi => $p->{c_psi},
2721             max_depth_used => $p->{max_depth_used},
2722             # Models saved before missing-value support lack these keys; default
2723             # to 'zero', which reproduces the old undef -> 0 scoring behaviour.
2724             missing => $p->{missing} // 'zero',
2725             impute_with => $p->{impute_with} // 'mean',
2726             missing_fill => $p->{missing_fill},
2727             feature_names => $p->{feature_names},
2728 20   100     630 trees => $trees,
      100        
      100        
2729             _use_c => $HAS_C,
2730             _use_openmp => $HAS_OPENMP,
2731             _use_openmp_fit => 0, # opt-in only; loaded models never re-fit implicitly
2732             };
2733 20 100       101 croak "model contains no trees" unless @{ $self->{trees} };
  20         216  
2734              
2735             # Recompute the normalising constant from the (integer, exact) sub-sample
2736             # size rather than trusting the stored float, so a reloaded model's scores
2737             # are bit-for-bit identical to the original's.
2738 19 50       191 $self->{c_psi} = _c( $self->{psi_used} ) if defined $self->{psi_used};
2739              
2740 19         91 my $model = bless $self, $class;
2741 19 50       278 $model->_rebuild_c_trees() if $self->{_use_c};
2742 19         283 return $model;
2743             } ## end sub from_json
2744              
2745             =head2 save($path)
2746              
2747             Saves the model to the specified path.
2748              
2749             $iforest->save($path);
2750              
2751             =cut
2752              
2753             sub save {
2754 1     1 1 4689 my ( $self, $path ) = @_;
2755 1         6 write_file( $path, { 'atomic' => 1 }, $self->to_json );
2756             }
2757              
2758             =head2 load($path)
2759              
2760             Init the object from the model in the specified file.
2761              
2762             my $iforest = Algorithm::Classifier::IsolationForest->load($path);
2763              
2764             =cut
2765              
2766             sub load {
2767 9     9 1 155067 my ( $class, $path ) = @_;
2768 9         60 my $raw_model = read_file($path);
2769 8         986 return $class->from_json($raw_model);
2770             }
2771              
2772             =head1 REFERENCES
2773              
2774             Liu, Fei Tony & Ting, Kai & Zhou, Zhi-Hua. (2008). Isolation Forest. 413 - 422. 10.1109/ICDM.2008.17.
2775              
2776             L
2777              
2778             L
2779              
2780             Sahand Hariri, Matias Carrasco Kind, Robert J. Brunner (2020). Extended Isolation Forest. 1479 - 1489. 10.1109/TKDE.2019.2947676
2781              
2782             L
2783              
2784             =cut
2785              
2786             ###
2787             ###
2788             ### internal stuff below
2789             ###
2790             ###
2791              
2792             #-------------------------------------------------------------------------------
2793             # c(n): the expected path length of an unsuccessful search in a binary search
2794             # tree of n nodes. Isolation Forest uses it (a) to adjust the path length when a
2795             # leaf still holds more than one point (depth limit reached), and (b) to
2796             # normalise the average path length into a 0..1 anomaly score.
2797             #-------------------------------------------------------------------------------
2798             sub _c {
2799 581580     581580   696144 my ($n) = @_;
2800 581580 100       983409 return 0.0 if $n <= 1;
2801 339714 100       498962 return 1.0 if $n == 2;
2802 281436         360568 my $harmonic = log( $n - 1 ) + EULER; # H(n-1) ~= ln(n-1) + gamma
2803 281436         527748 return 2.0 * $harmonic - ( 2.0 * ( $n - 1 ) / $n );
2804             }
2805              
2806             # One draw from the standard normal N(0,1) via Box-Muller. Used to pick the
2807             # random hyperplane orientations in Extended Isolation Forest mode.
2808             sub _randn {
2809 42769   50 42769   61185 my $u1 = rand() || 1e-12;
2810 42769         47550 my $u2 = rand();
2811 42769         66943 return sqrt( -2.0 * log($u1) ) * cos( TWO_PI * $u2 );
2812             }
2813              
2814             #-------------------------------------------------------------------------------
2815             # Draw $k samples without replacement via a partial Fisher-Yates shuffle of the
2816             # index array. Returns an arrayref of (shared, read-only) sample refs.
2817             #-------------------------------------------------------------------------------
2818             sub _subsample {
2819 2169     2169   4678 my ( $data, $k ) = @_;
2820 2169         3256 my $n = scalar @$data;
2821 2169         14785 my @idx = ( 0 .. $n - 1 );
2822 2169         5256 for my $i ( 0 .. $k - 1 ) {
2823 392648         455306 my $j = $i + int( rand( $n - $i ) );
2824 392648         516751 @idx[ $i, $j ] = @idx[ $j, $i ];
2825             }
2826 2169         30071 my @chosen = @idx[ 0 .. $k - 1 ];
2827 2169         9897 return [ @{$data}[@chosen] ];
  2169         42942  
2828             } ## end sub _subsample
2829              
2830             #-------------------------------------------------------------------------------
2831             # Recursively build one isolation tree.
2832             #
2833             # A node is one of:
2834             # leaf { leaf => 1, size => N }
2835             # axis { attr => A, split => S, left => ..., right => ... }
2836             # oblique { idx => [..], coef => [..], b => B, left => ..., right => ... }
2837             #
2838             # In both split styles the choice is restricted to features that actually vary
2839             # across the points reaching the node: this avoids wasted levels on constant
2840             # columns and lets a node leaf out exactly when its points are indistinguishable.
2841             #-------------------------------------------------------------------------------
2842             sub _build_tree {
2843 293547     293547   395236 my ( $self, $X, $depth, $limit ) = @_;
2844              
2845 293547         327231 my $size = scalar @$X;
2846 293547 100 100     680064 return [ _NODE_LEAF, $size ]
2847             if $depth >= $limit || $size <= 1;
2848              
2849 148484         191873 my $nf = $self->{n_features};
2850 148484         191479 my $nan = $self->{missing} eq 'nan';
2851              
2852             # Per-feature min and max within this node, in a single pass. Missing
2853             # (undef) cells never reach here under die/zero/impute -- those fill the
2854             # data before fit -- so the "next unless defined" guard is only needed
2855             # in nan mode, where missing values must not constrain a feature's
2856             # range; every other strategy skips it since every cell is defined and
2857             # the check would never fire.
2858 148484         168514 my ( @lo, @hi );
2859 148484 100       185644 if ($nan) {
2860 23338         28631 for my $row (@$X) {
2861 383160         478517 for my $f ( 0 .. $nf - 1 ) {
2862 770777         843652 my $v = $row->[$f];
2863 770777 100       1005991 next unless defined $v;
2864 698528 100 100     1362097 $lo[$f] = $v if !defined $lo[$f] || $v < $lo[$f];
2865 698528 100 100     1512791 $hi[$f] = $v if !defined $hi[$f] || $v > $hi[$f];
2866             }
2867             }
2868             } else {
2869 125146         156097 for my $row (@$X) {
2870 2588791         3201420 for my $f ( 0 .. $nf - 1 ) {
2871 7021721         7665550 my $v = $row->[$f];
2872 7021721 100 100     13646042 $lo[$f] = $v if !defined $lo[$f] || $v < $lo[$f];
2873 7021721 100 100     14872432 $hi[$f] = $v if !defined $hi[$f] || $v > $hi[$f];
2874             }
2875             }
2876             }
2877              
2878             # Features with spread are the only ones that can split the data. A
2879             # feature whose values are all missing within this node has an undef
2880             # range and is excluded.
2881 148484 100       231864 my @varying = grep { defined $lo[$_] && $lo[$_] < $hi[$_] } 0 .. $nf - 1;
  331276         766894  
2882              
2883             # No spread on any feature => all points identical => cannot isolate.
2884 148484 100       220385 return [ _NODE_LEAF, $size ] unless @varying;
2885              
2886             my $node
2887 145689 100       282919 = $self->{mode} eq 'extended'
2888             ? $self->_oblique_split( $X, \@varying, \@lo, \@hi, $nan )
2889             : _axis_split( $X, \@varying, \@lo, \@hi, $nan );
2890              
2891             # Split functions leave the raw point arrays at the child slots so that
2892             # _build_tree can recurse into them; the subtree refs replace them in-place.
2893             # Axis nodes: left at [3], right at [4]
2894             # Oblique nodes: left at [4], right at [5]
2895 145689 100       239628 my ( $li, $ri ) = $node->[0] == _NODE_AXIS ? ( 3, 4 ) : ( 4, 5 );
2896 145689         242547 $node->[$li] = $self->_build_tree( $node->[$li], $depth + 1, $limit );
2897 145689         214253 $node->[$ri] = $self->_build_tree( $node->[$ri], $depth + 1, $limit );
2898              
2899 145689         337096 return $node;
2900             } ## end sub _build_tree
2901              
2902             # Axis-parallel cut: random varying feature, random threshold in its range.
2903             # Returns [_NODE_AXIS, attr, split, \@left_pts, \@right_pts].
2904             # _build_tree overwrites slots 3 and 4 with the recursed subtrees.
2905             sub _axis_split {
2906 123302     123302   180739 my ( $X, $varying, $lo, $hi, $nan ) = @_;
2907              
2908 123302         193852 my $attr = $varying->[ int( rand( scalar @$varying ) ) ];
2909 123302         174578 my $split = $lo->[$attr] + rand() * ( $hi->[$attr] - $lo->[$attr] );
2910              
2911             # A point missing the split feature (nan mode only) routes to the right
2912             # child -- the same side NaN reaches in the C scorer, where (NaN < split)
2913             # is false. Under die/zero/impute every cell is defined, so the
2914             # "defined($v)" guard is dead weight there and skipped entirely.
2915 123302         141042 my ( @left, @right );
2916 123302 100       150403 if ($nan) {
2917 15096         18552 for my $row (@$X) {
2918 239367         256055 my $v = $row->[$attr];
2919 239367 100 100     427541 if ( defined($v) && $v < $split ) { push @left, $row }
  111298         152124  
2920 128069         177176 else { push @right, $row }
2921             }
2922             } else {
2923 108206         137866 for my $row (@$X) {
2924 2189925 100       2675247 if ( $row->[$attr] < $split ) { push @left, $row }
  1093141         1401645  
2925 1096784         1383669 else { push @right, $row }
2926             }
2927             }
2928 123302         320191 return [ _NODE_AXIS, $attr, $split, \@left, \@right ];
2929             } ## end sub _axis_split
2930              
2931             # Oblique cut (Extended Isolation Forest): a random hyperplane. We activate
2932             # (extension_level + 1) of the varying features, give each a Gaussian
2933             # coefficient, and place the plane through a random point in the bounding box.
2934             # A point goes left when coef . x <= b, where b = coef . p.
2935             # Returns [_NODE_OBLIQUE, \@idx, \@coef, $b, \@left_pts, \@right_pts].
2936             # _build_tree overwrites slots 4 and 5 with the recursed subtrees.
2937             sub _oblique_split {
2938 22387     22387   33787 my ( $self, $X, $varying, $lo, $hi, $nan ) = @_;
2939              
2940 22387         28834 my $active = $self->{extension_level_used} + 1;
2941 22387 100       34338 $active = scalar @$varying if $active > scalar @$varying;
2942              
2943             # Pick which varying features take part (partial shuffle of their indices).
2944 22387         31771 my @pool = @$varying;
2945 22387         31822 for my $i ( 0 .. $active - 1 ) {
2946 42769         58241 my $j = $i + int( rand( scalar(@pool) - $i ) );
2947 42769         63594 @pool[ $i, $j ] = @pool[ $j, $i ];
2948             }
2949 22387         38959 my @idx = @pool[ 0 .. $active - 1 ];
2950              
2951 22387         27487 my ( @coef, $b );
2952 22387         25216 $b = 0.0;
2953 22387         27960 for my $f (@idx) {
2954 42769         52243 my $c = _randn();
2955 42769         59383 my $p = $lo->[$f] + rand() * ( $hi->[$f] - $lo->[$f] ); # point in the box
2956 42769         53833 push @coef, $c;
2957 42769         53561 $b += $c * $p;
2958             }
2959              
2960             # A point missing any feature on the hyperplane (nan mode only) routes
2961             # to the right child: in the C scorer the dot product becomes NaN and
2962             # (NaN <= b) is false, so this keeps fit and score consistent. Under
2963             # die/zero/impute every cell is defined, so the per-feature "defined"
2964             # check and early-exit are dead weight there and skipped entirely.
2965 22387         26945 my ( @left, @right );
2966 22387 100       29591 if ($nan) {
2967 6947         8574 for my $row (@$X) {
2968 139686         146955 my $dot = 0.0;
2969 139686         143923 my $missing = 0;
2970 139686         195400 for ( 0 .. $#idx ) {
2971 262282         294225 my $v = $row->[ $idx[$_] ];
2972 262282 100       328324 if ( !defined $v ) { $missing = 1; last }
  26240         27281  
  26240         27964  
2973 236042         293488 $dot += $coef[$_] * $v;
2974             }
2975 139686 100 100     254368 if ( !$missing && $dot <= $b ) { push @left, $row }
  57761         77510  
2976 81925         114468 else { push @right, $row }
2977             } ## end for my $row (@$X)
2978             } else {
2979 15440         18913 for my $row (@$X) {
2980 394204         404669 my $dot = 0.0;
2981 394204         690880 $dot += $coef[$_] * $row->[ $idx[$_] ] for 0 .. $#idx;
2982 394204 100       480426 if ( $dot <= $b ) { push @left, $row }
  193814         240824  
2983 200390         248902 else { push @right, $row }
2984             }
2985             }
2986 22387         73709 return [ _NODE_OBLIQUE, \@idx, \@coef, $b, \@left, \@right ];
2987             } ## end sub _oblique_split
2988              
2989             #-------------------------------------------------------------------------------
2990             # Path length of a single point in a single tree: edges traversed until a leaf,
2991             # plus c(leaf size) when the leaf still holds several points.
2992             #
2993             # Node layout (arrayref, slot 0 = type):
2994             # _NODE_LEAF [0, size]
2995             # _NODE_AXIS [1, attr, split, left, right]
2996             # _NODE_OBLIQUE [2, \@idx, \@coef, b, left, right]
2997             #
2998             # The type tag is also used as a loop sentinel: 0 (_NODE_LEAF) is falsy.
2999             # No $self argument -- the node type encodes everything needed.
3000             #-------------------------------------------------------------------------------
3001             # The optional $nan flag selects the nan-strategy routing: a point missing
3002             # the split feature goes to the right child (matching the C scorer, where
3003             # the NaN comparison is false). Without it, undef is coerced to 0 -- the
3004             # behaviour the die/zero/impute strategies rely on (their data is dense by
3005             # the time it reaches here, so the "// 0" is normally a no-op).
3006             sub _path_length {
3007 340276     340276   443564 my ( $x, $node, $depth, $nan ) = @_;
3008 340276         459510 while ( $node->[0] ) { # false only for leaf (type 0)
3009 2481278 100       2989003 if ( $node->[0] == _NODE_AXIS ) { # [1, attr, split, left, right]
3010 2283722 100       2668214 if ($nan) {
3011 4852         5327 my $v = $x->[ $node->[1] ];
3012 4852 100 100     9421 $node = ( defined($v) && $v < $node->[2] ) ? $node->[3] : $node->[4];
3013             } else {
3014 2278870 100 100     3735941 $node = ( $x->[ $node->[1] ] // 0 ) < $node->[2] ? $node->[3] : $node->[4];
3015             }
3016             } else { # [2, \@idx, \@coef, b, left, right]
3017 197556         255472 my ( $idx, $coef, $b ) = ( $node->[1], $node->[2], $node->[3] );
3018 197556 100       228010 if ($nan) {
3019 3034         3146 my $dot = 0.0;
3020 3034         3194 my $missing = 0;
3021 3034         4061 for ( 0 .. $#$idx ) {
3022 4727         6023 my $v = $x->[ $idx->[$_] ];
3023 4727 100       5968 if ( !defined $v ) { $missing = 1; last }
  1920         1988  
  1920         2068  
3024 2807         3754 $dot += $coef->[$_] * $v;
3025             }
3026 3034 100 100     5627 $node = ( !$missing && $dot <= $b ) ? $node->[4] : $node->[5];
3027             } else {
3028 194522         196625 my $dot = 0.0;
3029 194522   100     445154 $dot += $coef->[$_] * ( $x->[ $idx->[$_] ] // 0 ) for 0 .. $#$idx;
3030 194522 100       268591 $node = $dot <= $b ? $node->[4] : $node->[5];
3031             }
3032             } ## end else [ if ( $node->[0] == _NODE_AXIS ) ]
3033 2481278         3339127 $depth++;
3034             } ## end while ( $node->[0] )
3035 340276         430786 return $depth + _c( $node->[1] ); # leaf size at slot 1
3036             } ## end sub _path_length
3037              
3038             # Recursively convert a version-0 hash-based tree node to the version-1
3039             # array format. Called by from_json when loading an old saved model.
3040             sub _hash_node_to_array {
3041 0     0   0 my ($node) = @_;
3042 0 0       0 if ( $node->{leaf} ) {
    0          
3043 0         0 return [ _NODE_LEAF, $node->{size} ];
3044             } elsif ( exists $node->{attr} ) {
3045             return [
3046             _NODE_AXIS, $node->{attr},
3047             $node->{split}, _hash_node_to_array( $node->{left} ),
3048 0         0 _hash_node_to_array( $node->{right} ),
3049             ];
3050             } else {
3051             return [
3052             _NODE_OBLIQUE, $node->{idx}, $node->{coef}, $node->{b},
3053             _hash_node_to_array( $node->{left} ),
3054 0         0 _hash_node_to_array( $node->{right} ),
3055             ];
3056             }
3057             } ## end sub _hash_node_to_array
3058              
3059             # ---------------------------------------------------------------------------
3060             # _pack_tree($root) -- flatten one tree into three packed buffers.
3061             #
3062             # Returns ($nodes_packed, $idx_packed, $val_packed) where:
3063             # nodes_packed: 6 doubles per node (see score_all_xs comment above)
3064             # idx_packed: int32 feature indices for every oblique-node coefficient
3065             # val_packed: double values matching idx_packed one-for-one
3066             #
3067             # Storing idx and val in separate buffers (SoA) instead of interleaved
3068             # doubles lets the oblique dot product's SIMD inner loop run over a
3069             # contiguous val[] stream without a per-iteration (int) cast, and
3070             # halves the index bandwidth (int32 vs double). The same `coff`
3071             # offset addresses paired entries in both buffers.
3072             #
3073             # Nodes are numbered in DFS pre-order: the root is always index 0 and
3074             # children always get indices larger than their parent's.
3075             # ---------------------------------------------------------------------------
3076             sub _pack_tree {
3077 4092     4092   6226 my ( $root, $n_features ) = @_;
3078 4092         7511 my ( @node_data, @coef_idx, @coef_val );
3079              
3080 4092         0 my $assign;
3081             $assign = sub {
3082 478238     478238   567194 my ($node) = @_;
3083 478238         513545 my $my_idx = scalar @node_data;
3084 478238         570721 push @node_data, undef; # reserve slot; filled in after children
3085              
3086 478238 100       728606 if ( $node->[0] == _NODE_LEAF ) {
    100          
3087              
3088             # Slot 2 carries c(size) precomputed, so the C scoring loop
3089             # adds it straight to the depth instead of paying a log()
3090             # per point per tree at every leaf hit. _c is the same
3091             # function the pure-Perl scorer uses, so both backends keep
3092             # producing bit-identical path lengths.
3093 241165         316834 $node_data[$my_idx] = [ 0.0, $node->[1] + 0.0, _c( $node->[1] ), 0.0, 0.0, 0.0 ];
3094             } elsif ( $node->[0] == _NODE_AXIS ) {
3095 208282         322333 my $li = $assign->( $node->[3] );
3096 208282         264123 my $ri = $assign->( $node->[4] );
3097 208282         398655 $node_data[$my_idx] = [
3098             1.0,
3099             $node->[1] + 0.0, # attr
3100             $node->[2] + 0.0, # split
3101             $li + 0.0,
3102             $ri + 0.0,
3103             0.0,
3104             ];
3105             } else { # _NODE_OBLIQUE
3106 28791         41873 my ( $idx_arr, $coef_arr, $b ) = ( $node->[1], $node->[2], $node->[3] );
3107 28791         31892 my $coef_off = scalar @coef_idx;
3108 28791         33870 my $num = scalar @$idx_arr;
3109              
3110             # Dense-pack opportunity: when this oblique split uses
3111             # every feature (extension_level == n_features - 1 and
3112             # all features vary), pack the coefficients in feature
3113             # order so val[k] is the coefficient for feature k. The
3114             # C scoring path then detects `nf == n_feats` and switches
3115             # to a no-gather inner loop (dot += val[k] * xi[k]) that
3116             # auto-vectorizes cleanly with FMA.
3117 28791 100 66     57556 if ( defined $n_features && $num == $n_features ) {
3118 24985         27570 my %coef_for;
3119 24985         53183 @coef_for{@$idx_arr} = @$coef_arr;
3120 24985         35144 for my $k ( 0 .. $n_features - 1 ) {
3121 58167         78030 push @coef_idx, $k;
3122 58167         96450 push @coef_val, $coef_for{$k} + 0.0;
3123             }
3124             } else {
3125 3806         5064 for my $i ( 0 .. $num - 1 ) {
3126 3811         5409 push @coef_idx, int( $idx_arr->[$i] );
3127 3811         5812 push @coef_val, $coef_arr->[$i] + 0.0;
3128             }
3129             }
3130              
3131 28791         52159 my $li = $assign->( $node->[4] );
3132 28791         37433 my $ri = $assign->( $node->[5] );
3133 28791         60391 $node_data[$my_idx] = [ 2.0, $coef_off + 0.0, $num + 0.0, $li + 0.0, $ri + 0.0, $b + 0.0, ];
3134             } ## end else [ if ( $node->[0] == _NODE_LEAF ) ]
3135 478238         578962 return $my_idx;
3136 4092         22170 }; ## end $assign = sub
3137 4092         8251 $assign->($root);
3138              
3139 4092         8820 my $nodes_packed = pack( 'd*', map { @$_ } @node_data );
  478238         858420  
3140 4092 100       58180 my $idx_packed = @coef_idx ? pack( 'l*', @coef_idx ) : pack('l*');
3141 4092 100       8329 my $val_packed = @coef_val ? pack( 'd*', @coef_val ) : pack('d*');
3142 4092         11862 return ( $nodes_packed, $idx_packed, $val_packed );
3143             } ## end sub _pack_tree
3144              
3145             # Build packed C-ready representations for all trees and store them in
3146             # $self->{_c_nodes}, $self->{_c_coef_idx}, $self->{_c_coef_val}.
3147             # Called after fit() and from_json() when _use_c is true. n_features is
3148             # threaded through so _pack_tree can spot the dense-pack opportunity.
3149             sub _rebuild_c_trees {
3150 92     92   295 my ($self) = @_;
3151 92         239 my ( @c_nodes, @c_coef_idx, @c_coef_val );
3152 92         192 for my $tree ( @{ $self->{trees} } ) {
  92         325  
3153 4092         8713 my ( $np, $ip, $vp ) = _pack_tree( $tree, $self->{n_features} );
3154 4092         7502 push @c_nodes, $np;
3155 4092         5850 push @c_coef_idx, $ip;
3156 4092         8726 push @c_coef_val, $vp;
3157             }
3158 92         468 $self->{_c_nodes} = \@c_nodes;
3159 92         648 $self->{_c_coef_idx} = \@c_coef_idx;
3160 92         296 $self->{_c_coef_val} = \@c_coef_val;
3161             } ## end sub _rebuild_c_trees
3162              
3163             sub _check_fitted {
3164 56025     56025   96467 my ($self) = @_;
3165             croak "model is not fitted yet; call fit() first"
3166 56025 100 66     129702 unless ref $self->{trees} eq 'ARRAY' && @{ $self->{trees} };
  56025         165438  
3167             }
3168              
3169             # Memoised "does this perl have a real fork()?". False on Windows
3170             # without Cygwin; true on every Unix-like platform.
3171             {
3172             my $cached;
3173              
3174             sub _fork_supported {
3175 8 100   8   38 return $cached if defined $cached;
3176 2         24 require Config;
3177             $cached
3178 2 50 50     63 = ( ( $Config::Config{d_fork} || '' ) eq 'define' ) ? 1 : 0;
3179 2         11 return $cached;
3180             }
3181             }
3182              
3183             #-------------------------------------------------------------------------------
3184             # Fork-based parallel tree builder. Used by fit() when parallel_fit > 1
3185             # and the platform has a real fork(). Divides n_trees evenly among
3186             # workers; each child seeds its own RNG ($seed + worker_id * 1009 so
3187             # fixed-worker-count runs are reproducible), builds its share (via the
3188             # C builder when _use_c is on, same as the non-parallel path), and
3189             # returns the trees to the parent via Storable on a one-shot pipe.
3190             #
3191             # The trees that come back differ from a serial fit with the same seed
3192             # because the RNG draws happen in a different order -- this is documented
3193             # as part of the parallel_fit contract.
3194             #-------------------------------------------------------------------------------
3195             sub _fit_trees_parallel {
3196 8     8   24 my ( $self, $data, $psi, $limit, $workers ) = @_;
3197 8         77 require Storable;
3198 8         29 require POSIX;
3199              
3200 8         32 my $n_trees = $self->{n_trees};
3201 8 100       22 $workers = $n_trees if $workers > $n_trees;
3202              
3203             # Divide n_trees as evenly as possible across workers.
3204 8         15 my @shares;
3205             {
3206 8         13 my $base = int( $n_trees / $workers );
  8         22  
3207 8         17 my $extras = $n_trees - $base * $workers;
3208 8         27 for my $w ( 0 .. $workers - 1 ) {
3209 26 100       69 push @shares, $base + ( $w < $extras ? 1 : 0 );
3210             }
3211             }
3212              
3213 8         10 my @procs; # { pid, rh, share }
3214 8         18 for my $w ( 0 .. $workers - 1 ) {
3215 26         174 my $share = $shares[$w];
3216 26 50       175 next unless $share > 0;
3217              
3218 26 50       2750 pipe( my $rh, my $wh ) or croak "pipe failed: $!";
3219 26         57618 my $pid = fork();
3220 26 50       1802 croak "fork failed: $!" unless defined $pid;
3221              
3222 26 50       978 if ( $pid == 0 ) {
3223             # child
3224 0         0 close $rh;
3225 0         0 binmode $wh;
3226 0 0       0 if ( defined $self->{seed} ) {
3227 0         0 srand( $self->{seed} + $w * 1009 );
3228             }
3229             # Deliberately never _build_forest_openmp here, even when
3230             # use_openmp_fit is on: if this process (or the parent that
3231             # fork()ed us) already ran any OpenMP region before this
3232             # fork -- including plain score_samples()/predict() with
3233             # the default use_openmp -- libgomp's thread pool exists
3234             # but its worker threads didn't survive the fork. A child
3235             # starting its own #pragma omp parallel region then tries
3236             # to reuse that now-invalid pool and hangs. This is a
3237             # general fork()+libgomp limitation, not fixable from here,
3238             # so forked workers always use the single-threaded C
3239             # builder (or pure Perl) instead. See t/03-fit-determinism.t
3240             # and the NATIVE ACCELERATION docs for the observed hang and
3241             # why parallel_fit + use_openmp_fit isn't composed for real.
3242 0         0 my $trees;
3243 0 0       0 if ( $self->{_use_c} ) {
3244 0         0 $trees = $self->_build_forest_c( $data, $psi, $limit, $share );
3245             } else {
3246 0         0 my @t;
3247 0         0 for ( 1 .. $share ) {
3248 0         0 my $sample = _subsample( $data, $psi );
3249 0         0 push @t, $self->_build_tree( $sample, 0, $limit );
3250             }
3251 0         0 $trees = \@t;
3252             }
3253 0         0 print $wh Storable::freeze($trees);
3254 0         0 close $wh;
3255             # _exit so we don't run parent END/DESTROY in the child.
3256 0         0 POSIX::_exit(0);
3257             } ## end if ( $pid == 0 )
3258              
3259 26         1742 close $wh;
3260 26         499 binmode $rh;
3261 26         6300 push @procs, { pid => $pid, rh => $rh, share => $share };
3262             } ## end for my $w ( 0 .. $workers - 1 )
3263              
3264             # Collect from each pipe in worker order so the canonical tree
3265             # ordering is deterministic (worker 0's trees first, then 1's, ...).
3266 8         180 my @all_trees;
3267 8         129 for my $p (@procs) {
3268 26         126 my $buf;
3269             {
3270 26         86 local $/;
  26         813  
3271 26         36850 $buf = readline( $p->{rh} );
3272             }
3273 26         382 close $p->{rh};
3274 26         34187 waitpid( $p->{pid}, 0 );
3275 26         290 my $exit = $? >> 8;
3276 26 50       157 croak "parallel_fit worker $p->{pid} exited with status $exit"
3277             if $exit != 0;
3278 26         79 my $trees = eval { Storable::thaw($buf) };
  26         401  
3279 26 50 33     26590 croak "parallel_fit worker $p->{pid} returned unparseable trees: $@"
3280             if $@ || ref $trees ne 'ARRAY';
3281 26         232 push @all_trees, @$trees;
3282             } ## end for my $p (@procs)
3283              
3284 8         286 return \@all_trees;
3285             } ## end sub _fit_trees_parallel
3286              
3287             #-------------------------------------------------------------------------------
3288             # C-accelerated fit(): builds $n_trees trees against $data (a subset or
3289             # the full training set) via build_forest_xs, which does its own
3290             # per-tree subsampling internally. Random draws inside the C builder
3291             # go through Drand01() -- the same generator Perl's rand() uses -- in
3292             # the same call order _subsample/_build_tree used, so the returned
3293             # trees are bit-identical to what the pure-Perl path would build from
3294             # the same RNG state. That's what lets fit() switch backends on the
3295             # existing `use_c` knob instead of a new one.
3296             #-------------------------------------------------------------------------------
3297             sub _build_forest_c {
3298 58     58   186 my ( $self, $data, $psi, $limit, $n_trees ) = @_;
3299 58         108 my $n = scalar @$data;
3300 58         142 my $nf = $self->{n_features};
3301 58         10674 my $x_packed = "\0" x ( $n * $nf * 8 );
3302 58         305 my ( $mode, $fill ) = $self->_pack_args;
3303 58         944 pack_input_xs( $data, $x_packed, $n, $nf, $mode, $fill );
3304              
3305 58 100       184 my $mode_flag = $self->{mode} eq 'extended' ? 1 : 0;
3306 58   100     559 my $ext_level = $self->{extension_level_used} // 0;
3307              
3308 58         145 my $trees = [];
3309 58         233154 build_forest_xs( $x_packed, $n, $nf, $n_trees, $psi, $limit, $mode_flag, $ext_level, $trees );
3310 58         417 return $trees;
3311             } ## end sub _build_forest_c
3312              
3313             #-------------------------------------------------------------------------------
3314             # OpenMP-parallel fit(): builds $n_trees trees across OpenMP threads (one
3315             # tree per thread) via build_forest_openmp_xs. Unlike _build_forest_c,
3316             # random draws come from a thread-private PRNG seeded per tree index
3317             # rather than Drand01() -- Perl's RNG state can't be shared safely
3318             # across OpenMP threads -- so the resulting trees are NOT bit-identical
3319             # to the use_c (serial) or pure-Perl paths for the same seed, though a
3320             # fixed seed + n_trees still reproduce the same trees regardless of
3321             # OMP_NUM_THREADS. This is why it's gated by the separate, opt-in
3322             # use_openmp_fit knob rather than reusing use_c/use_openmp.
3323             #
3324             # Only called from fit()'s non-forked branch. _fit_trees_parallel's
3325             # workers never call this, even when use_openmp_fit is on: a forked
3326             # child starting its own OpenMP region after the parent process has
3327             # used OpenMP for anything (this includes plain score_samples()) can
3328             # hang -- see the comment above that branch for the fork()+libgomp
3329             # hazard this avoids.
3330             #
3331             # build_forest_openmp_xs hands back three arrayrefs of per-tree packed
3332             # buffers (the same SoA layout _pack_tree produces) instead of Perl tree
3333             # structures -- that's how it avoids any Perl API call inside its
3334             # parallel region. _unpack_forest converts them back into the ordinary
3335             # nested-arrayref tree shape so to_json/from_json/_rebuild_c_trees don't
3336             # need to know this path exists.
3337             #-------------------------------------------------------------------------------
3338             sub _build_forest_openmp {
3339 7     7   25 my ( $self, $data, $psi, $limit, $n_trees ) = @_;
3340 7         12 my $n = scalar @$data;
3341 7         23 my $nf = $self->{n_features};
3342 7         332 my $x_packed = "\0" x ( $n * $nf * 8 );
3343 7         34 my ( $mode, $fill ) = $self->_pack_args;
3344 7         110 pack_input_xs( $data, $x_packed, $n, $nf, $mode, $fill );
3345              
3346 7 100       23 my $mode_flag = $self->{mode} eq 'extended' ? 1 : 0;
3347 7   100     26 my $ext_level = $self->{extension_level_used} // 0;
3348              
3349 7         15 my ( @nodes, @idx, @val );
3350 7         11377 build_forest_openmp_xs( $x_packed, $n, $nf, $n_trees, $psi, $limit,
3351             $mode_flag, $ext_level, \@nodes, \@idx, \@val, 1 );
3352              
3353 7         64 return _unpack_forest( \@nodes, \@idx, \@val );
3354             } ## end sub _build_forest_openmp
3355              
3356             # Inverse of _pack_tree's SoA layout: given one tree's packed node
3357             # buffer plus the shared idx/val coefficient buffers, reconstructs the
3358             # ordinary nested-arrayref tree structure _build_tree/_build_node_c
3359             # produce. li/ri fields hold the child's absolute node index, so this
3360             # just follows them recursively from whatever index the caller says the
3361             # root lives at. NOTE: _pack_tree numbers nodes DFS pre-order (root at
3362             # 0), but build_forest_openmp_xs appends nodes post-order (children
3363             # before parent), putting the root LAST -- the caller must pass the
3364             # right root index for the buffer's origin.
3365             sub _unpack_node {
3366 12844     12844   16773 my ( $nodes, $idx, $val, $node_i ) = @_;
3367 12844         13988 my $off = $node_i * 6;
3368 12844         14069 my $type = $nodes->[$off];
3369              
3370 12844 100       17170 if ( $type == 0 ) {
    100          
3371 6582         20047 return [ _NODE_LEAF, int( $nodes->[ $off + 1 ] ) ];
3372             } elsif ( $type == 1 ) {
3373             my ( $attr, $split, $li, $ri )
3374 1604         1951 = @{$nodes}[ $off + 1 .. $off + 4 ];
  1604         2242  
3375             return [
3376 1604         2576 _NODE_AXIS, int($attr), $split,
3377             _unpack_node( $nodes, $idx, $val, int($li) ),
3378             _unpack_node( $nodes, $idx, $val, int($ri) ),
3379             ];
3380             } else {
3381 4658         5782 my ( $coff, $num, $li, $ri, $b ) = @{$nodes}[ $off + 1 .. $off + 5 ];
  4658         6568  
3382 4658         5487 $coff = int($coff);
3383 4658         5010 $num = int($num);
3384             return [
3385             _NODE_OBLIQUE,
3386 4658         7455 [ @{$idx}[ $coff .. $coff + $num - 1 ] ],
3387 4658         5510 [ @{$val}[ $coff .. $coff + $num - 1 ] ],
  4658         8408  
3388             $b,
3389             _unpack_node( $nodes, $idx, $val, int($li) ),
3390             _unpack_node( $nodes, $idx, $val, int($ri) ),
3391             ];
3392             } ## end else [ if ( $type == 0 ) ]
3393             } ## end sub _unpack_node
3394              
3395             # Unpacks every tree in the three per-tree packed-buffer arrayrefs
3396             # build_forest_openmp_xs returns into the ordinary nested tree shape.
3397             # The C builder pushes nodes post-order (a node is recorded after both
3398             # of its children), so each tree's root is the LAST node record, not
3399             # index 0 as in _pack_tree's pre-order layout.
3400             sub _unpack_forest {
3401 7     7   24 my ( $nodes_list, $idx_list, $val_list ) = @_;
3402 7         13 my @trees;
3403 7         28 for my $i ( 0 .. $#$nodes_list ) {
3404 320         2230 my @nodes = unpack( 'd*', $nodes_list->[$i] );
3405 320         1031 my @idx = unpack( 'l*', $idx_list->[$i] );
3406 320         958 my @val = unpack( 'd*', $val_list->[$i] );
3407 320         602 my $root = @nodes / 6 - 1;
3408 320         560 push @trees, _unpack_node( \@nodes, \@idx, \@val, $root );
3409             }
3410 7         62 return \@trees;
3411             } ## end sub _unpack_forest
3412              
3413             #-------------------------------------------------------------------------------
3414             # Packed input wrapper. pack_data() returns one of these so callers can
3415             # score the same dataset many times without re-walking the AV/AV refs on
3416             # every call -- a meaningful win at high feature counts where
3417             # pack_input_xs is a non-trivial slice of total scoring time.
3418             #
3419             # It's a minimal blessed hashref: { packed, n_pts, n_feats }. The C
3420             # scoring functions only need the packed bytes + dimensions.
3421             #-------------------------------------------------------------------------------
3422             sub pack_data {
3423 7     7 1 2049 my ( $self, $data ) = @_;
3424 7         29 $self->_check_fitted;
3425             croak "pack_data requires the Inline::C backend; install Inline::C"
3426 6 100       239 unless $self->{_use_c};
3427 5 100       246 croak "pack_data() expects an arrayref of samples"
3428             unless ref $data eq 'ARRAY';
3429 3         5 my $n_pts = scalar @$data;
3430 3         6 my $nf = $self->{n_features};
3431 3         18 my $x_packed = "\0" x ( $n_pts * $nf * 8 );
3432 3         16 my ( $mode, $fill ) = $self->_pack_args;
3433 3         75 pack_input_xs( $data, $x_packed, $n_pts, $nf, $mode, $fill );
3434 3         33 return bless {
3435             packed => $x_packed,
3436             n_pts => $n_pts,
3437             n_feats => $nf,
3438             },
3439             'Algorithm::Classifier::IsolationForest::PackedData';
3440             } ## end sub pack_data
3441              
3442             # Internal helper: given $data that may be a raw arrayref OR a PackedData
3443             # instance, return the (n_pts, n_feats, x_packed) triple ready for
3444             # score_all_xs. Called from every scoring fast path.
3445             sub _resolve_input {
3446 55916     55916   98752 my ( $self, $data ) = @_;
3447 55916 100       121329 if ( ref $data eq 'Algorithm::Classifier::IsolationForest::PackedData' ) {
3448             croak "PackedData has $data->{n_feats} features but model expects " . $self->{n_features}
3449 25815 100       55098 unless $data->{n_feats} == $self->{n_features};
3450 25814         67946 return ( $data->{n_pts}, $data->{n_feats}, $data->{packed} );
3451             }
3452 30101         49508 my $n_pts = scalar @$data;
3453 30101         52950 my $nf = $self->{n_features};
3454 30101         57861 my $x_packed = "\0" x ( $n_pts * $nf * 8 );
3455 30101         65305 my ( $mode, $fill ) = $self->_pack_args;
3456 30101         107045 pack_input_xs( $data, $x_packed, $n_pts, $nf, $mode, $fill );
3457 30101         73415 return ( $n_pts, $nf, $x_packed );
3458             } ## end sub _resolve_input
3459              
3460             # Helper used by the pure-Perl fallback paths: convert either form back
3461             # to an arrayref-of-arrayrefs. Slow on PackedData -- the whole point of
3462             # packing is to keep things in C -- but lets the fallback path be
3463             # uniformly arrayref-driven.
3464             sub _to_arrayref {
3465 80     80   147 my ( $self, $data ) = @_;
3466 80 50       335 return $data if ref $data eq 'ARRAY';
3467 0 0       0 if ( ref $data eq 'Algorithm::Classifier::IsolationForest::PackedData' ) {
3468 0         0 my $n_pts = $data->{n_pts};
3469 0         0 my $nf = $data->{n_feats};
3470 0         0 my @doubles = unpack( 'd*', $data->{packed} );
3471 0         0 my @rows;
3472 0         0 for my $i ( 0 .. $n_pts - 1 ) {
3473 0         0 push @rows, [ @doubles[ $i * $nf .. ( $i + 1 ) * $nf - 1 ] ];
3474             }
3475 0         0 return \@rows;
3476             } ## end if ( ref $data eq 'Algorithm::Classifier::IsolationForest::PackedData')
3477 0   0     0 croak "expected arrayref or PackedData, got " . ( ref($data) || 'scalar' );
3478             } ## end sub _to_arrayref
3479              
3480             # ---------------------------------------------------------------------------
3481             # Missing-value handling.
3482             #
3483             # The `missing` strategy chosen at new() decides how undef feature cells are
3484             # treated. Scoring always tolerates undef; the strategy governs fit() and
3485             # how undef is represented for the scorer:
3486             #
3487             # die -- croak from fit() if the training data holds any undef cell.
3488             # Scoring still maps undef -> 0 (the long-standing behaviour).
3489             # zero -- undef counts as the value 0, at fit and score time.
3490             # impute -- undef is replaced by a learned per-feature mean/median; the
3491             # fill vector is stored on the model and reused at score time.
3492             # nan -- ranges are built over present values only and a point missing
3493             # the split feature is routed to the right child, consistently
3494             # at fit (Perl) and score (C packs NaN; `<`/`<=` send it right).
3495             # ---------------------------------------------------------------------------
3496              
3497             # Returns the training data to actually build trees on, after applying the
3498             # missing-value strategy. May croak (die), return a dense filled copy
3499             # (zero/impute), or pass $data through unchanged (nan).
3500             sub _prepare_fit_data {
3501 126     126   251 my ( $self, $data ) = @_;
3502 126         265 my $m = $self->{missing};
3503 126         245 my $nf = $self->{n_features};
3504              
3505 126 100       299 if ( $m eq 'die' ) {
3506 100         383 for my $i ( 0 .. $#$data ) {
3507 14706         16751 my $row = $data->[$i];
3508 14706         18227 for my $f ( 0 .. $nf - 1 ) {
3509 40681 100       59586 next if defined $row->[$f];
3510 2         519 croak "fit(): undef feature value at sample $i, column $f; "
3511             . "construct with missing => 'zero', 'impute', or 'nan' "
3512             . "to train on data with missing values";
3513             }
3514             }
3515 98         283 return $data;
3516             } ## end if ( $m eq 'die' )
3517              
3518             # nan: leave undef in place -- _build_tree / the split routers handle it.
3519 26 100       68 return $data if $m eq 'nan';
3520              
3521             # zero / impute: undef has to become a real number somewhere before a
3522             # split can look at it. The fill vector is computed either way (it's
3523             # needed for persistence and for scoring later), but densifying $data
3524             # into a second, fully separate Perl array here is only necessary for
3525             # the pure-Perl tree builder (_build_tree assumes every cell is
3526             # defined once missing != 'nan' -- see its lo/hi scan). The C
3527             # tree-building path -- _build_forest_c/_build_forest_openmp, and
3528             # every parallel_fit worker, all of which go through pack_input_xs --
3529             # already fills undef cells itself from this same fill vector, so
3530             # skip the redundant whole-dataset copy when that's the path fit()
3531             # will actually take. Scoring the training set for a learned
3532             # contamination threshold (below, in fit()) is unaffected: it always
3533             # runs through the pure-Perl scorer regardless of use_c (fit() drops
3534             # any previous fit's packed buffers before that scoring, and
3535             # _rebuild_c_trees runs after), and that path already tolerates raw
3536             # undef cells
3537             # for both zero (_path_length's "// 0") and impute (_prepare_perl_input
3538             # densifies on demand from missing_fill).
3539 18 100       223 my $fill
3540             = $m eq 'impute'
3541             ? $self->_compute_impute_fill($data)
3542             : [ (0) x $nf ];
3543 14 100       58 $self->{missing_fill} = $fill if $m eq 'impute';
3544 14         53 delete $self->{_fill_packed};
3545              
3546 14 100       67 return $data if $self->{_use_c};
3547 7         25 return _densify( $data, $fill );
3548             } ## end sub _prepare_fit_data
3549              
3550             # Per-feature fill value (mean or median of the present values) for impute
3551             # mode. Croaks if a feature has no present value to learn from.
3552             sub _compute_impute_fill {
3553 12     12   27 my ( $self, $data ) = @_;
3554 12         29 my $nf = $self->{n_features};
3555 12         33 my $how = $self->{impute_with};
3556              
3557             # C fast path: walks the raw data directly and finds the median via
3558             # quickselect (O(n) average) instead of the Perl fallback's full sort
3559             # (O(n log n)). Produces the same fill values either way -- see
3560             # impute_fill_xs's file-top comment -- so use_c only changes speed
3561             # here, matching the rest of the module.
3562 12 100       63 if ( $self->{_use_c} ) {
3563 6         12 my $n = scalar @$data;
3564 6 100       33 my $how_flag = $how eq 'median' ? 1 : 0;
3565 6         10 my $fill = [];
3566 6         6954 impute_fill_xs( $data, $n, $nf, $how_flag, $fill );
3567 4         23 return $fill;
3568             }
3569              
3570 6         11 my @fill;
3571 6         23 for my $f ( 0 .. $nf - 1 ) {
3572 12         33 my @vals = grep { defined } map { $_->[$f] } @$data;
  2484         5610  
  2484         2889  
3573 12 100       587 croak "impute: feature column $f has no present values"
3574             unless @vals;
3575 10 100       26 if ( $how eq 'median' ) {
3576 4         1661 my @s = sort { $a <=> $b } @vals;
  2942         3726  
3577 4         9 my $k = scalar @s;
3578 4 100       59 $fill[$f]
3579             = $k % 2
3580             ? $s[ int( $k / 2 ) ]
3581             : ( $s[ $k / 2 - 1 ] + $s[ $k / 2 ] ) / 2.0;
3582             } else { # mean
3583 6         10 my $sum = 0;
3584 6         109 $sum += $_ for @vals;
3585 6         34 $fill[$f] = $sum / scalar @vals;
3586             }
3587             } ## end for my $f ( 0 .. $nf - 1 )
3588 4         15 return \@fill;
3589             } ## end sub _compute_impute_fill
3590              
3591             # Return a dense copy of $data with every undef cell replaced by the
3592             # matching per-feature fill value. Leaves present cells untouched.
3593             sub _densify {
3594 10     10   23 my ( $data, $fill ) = @_;
3595 10         18 my $nf = scalar @$fill;
3596             return [
3597             map {
3598 10         38 my $r = $_;
  1886         2662  
3599 1886 100       2151 [ map { defined $r->[$_] ? $r->[$_] : $fill->[$_] } 0 .. $nf - 1 ]
  4078         6709  
3600             } @$data
3601             ];
3602             } ## end sub _densify
3603              
3604             # (miss_mode, fill_packed) pair for pack_input_xs, per the active strategy.
3605             # die/zero -> 0 (undef becomes 0.0); impute -> 1 (undef becomes fill[k]);
3606             # nan -> 2 (undef becomes NaN, which the C scorer routes right).
3607             sub _pack_args {
3608 30169     30169   52570 my ($self) = @_;
3609 30169         53642 my $m = $self->{missing};
3610 30169 100       64120 return ( 2, '' ) if $m eq 'nan';
3611 30160 100       60949 if ( $m eq 'impute' ) {
3612 9         23 my $fill = $self->{missing_fill};
3613             croak "impute model is missing its fill vector"
3614 9 50 33     59 unless ref $fill eq 'ARRAY' && @$fill == $self->{n_features};
3615 9   66     60 $self->{_fill_packed} //= pack( 'd*', @$fill );
3616 9         34 return ( 1, $self->{_fill_packed} );
3617             }
3618 30151         63884 return ( 0, '' ); # die, zero
3619             } ## end sub _pack_args
3620              
3621             # Pure-Perl fallback input prep: arrayref-ify, then fill for impute so the
3622             # tree walk sees dense rows. zero/die rely on _path_length's "// 0"; nan
3623             # keeps undef in place for _path_length to route. Returns the rows; the
3624             # caller passes the nan flag to _path_length separately.
3625             sub _prepare_perl_input {
3626 59     59   144 my ( $self, $data ) = @_;
3627 59         237 my $rows = $self->_to_arrayref($data);
3628 59 100       186 if ( $self->{missing} eq 'impute' ) {
3629             croak "impute model is missing its fill vector"
3630 3 50       16 unless ref $self->{missing_fill} eq 'ARRAY';
3631 3         13 $rows = _densify( $rows, $self->{missing_fill} );
3632             }
3633 59         114 return $rows;
3634             } ## end sub _prepare_perl_input
3635              
3636             # Minimal PackedData package: opaque token returned by pack_data().
3637             # Exposes n_pts and n_feats accessors for users who want to introspect.
3638             {
3639              
3640             package Algorithm::Classifier::IsolationForest::PackedData;
3641 4     4   626 sub n_pts { $_[0]->{n_pts} }
3642 4     4   19 sub n_feats { $_[0]->{n_feats} }
3643             }
3644              
3645             1;