File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/stream.pm
Criterion Covered Total %
statement 122 142 85.9
branch 81 122 66.3
condition 24 42 57.1
subroutine 10 12 83.3
pod 4 5 80.0
total 241 323 74.6


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::stream;
2              
3 81     81   55766 use strict;
  81         169  
  81         2632  
4 81     81   315 use warnings;
  81         155  
  81         2990  
5 81     81   390 use Algorithm::Classifier::IsolationForest ();
  81         135  
  81         1119  
6 81     81   46130 use Algorithm::Classifier::IsolationForest::Online ();
  81         251  
  81         2306  
7 81     81   512 use Algorithm::Classifier::IsolationForest::App -command;
  81         130  
  81         619  
8 81     81   23415 use File::Slurp qw(read_file write_file);
  81         156  
  81         3881  
9 81     81   363 use Scalar::Util qw(looks_like_number);
  81         126  
  81         142296  
10              
11             sub opt_spec {
12             return (
13             [
14 10     10 1 254508 'm=s',
15             'Online model JSON file path/name. Created if it does not exist; resumed and updated if it does.',
16             { 'default' => 'oiforest_model.json', 'completion' => 'files' }
17             ],
18             [ 'i=s', 'Input CSV to stream through the model, in row order.', { 'completion' => 'files' } ],
19             [ 'o=s', 'Output the scores to this file instead of printing.', { 'completion' => 'files' } ],
20             [ 'w', 'If the file specified via -o exists, over write it.' ],
21             [ 'd', 'Include the input data in the output.' ],
22             [
23             'learn-only',
24             'Only learn the input (warm-up); no scores are emitted. May not be combined with --score-only.'
25             ],
26             [
27             'score-only',
28             'Only score the input against the model as-is; nothing is learned. May not be combined with --learn-only.'
29             ],
30             [ 'threshold=f', 'Alternative decision threshold to use for the label column. 0 < $val < 1' ],
31             [
32             'save!',
33             'Save the updated model state back to -m after streaming (default on; --no-save to discard).',
34             { 'default' => 1 }
35             ],
36              
37             # creation knobs, used only when -m does not exist yet
38             [ 'n=i', 'Number of isolation trees in the ensemble (new models only).' ],
39             [ 'window=i', 'Sliding window size; 0 disables forgetting (new models only).' ],
40             [ 'eta=i', 'max_leaf_samples: points a leaf accumulates before splitting (new models only).' ],
41             [ 'growth=s', "Leaf split-requirement growth, 'adaptive' or 'fixed' (new models only)." ],
42             [ 'subsample=f', 'Per-tree stream subsampling probability, in (0, 1] (new models only).' ],
43             [ 's=i', 'Seed int (new models only).' ],
44             [
45             'c=f',
46             'Contamination. Expected fraction of anomalies, in (0, 0.5]; learns the decision threshold from the window (new models only).'
47             ],
48             [
49             't=s@',
50             'Feature name tag. Pass once per feature (e.g. -t cpu -t mem -t disk); the count must match the number of CSV columns or the command will die (new models only).'
51             ],
52             [
53             'mungers=s',
54             'JSON file of Algorithm::ToNumberMunger specs, keyed by feature tag (new models only; requires -t). '
55             . 'Munged CSV columns may hold raw values; rows are munged before streaming and the spec is '
56             . 'saved with the model, so resumed runs munge identically. Scalar mungers only for CSV input.',
57             { 'completion' => 'files' }
58             ],
59             [
60             'prototype=s',
61             'JSON prototype file to create the model from (new models only): the variable schema and '
62             . 'schema_version/schema_description come from it, and its params supply knob defaults that the '
63             . 'creation switches override. May not be combined with -t or --mungers. See PROTOTYPES in the '
64             . 'module POD.',
65             { 'completion' => 'files' }
66             ],
67             );
68             } ## end sub opt_spec
69              
70 0     0 1 0 sub abstract { 'Stream CSV rows through an Online Isolation Forest model, scoring and learning as it goes' }
71              
72             sub description {
73 0     0 1 0 'Streams the input rows, in order, through an Online Isolation Forest
74             model (Algorithm::Classifier::IsolationForest::Online).
75              
76             The default operation is prequential: each row is scored against the
77             model as it stood before that row was learned, then learned, and the
78             model state (including its sliding window) is saved back to -m so the
79             next invocation resumes the stream where this one left off. --learn-only
80             skips the scoring (warm-up) and --score-only skips the learning.
81              
82             If -m does not exist yet a new model is created using the creation knobs
83             (-n, --window, --eta, --growth, --subsample, -s, -c, -t, --mungers,
84             --prototype); when it does exist those knobs are ignored. With
85             --prototype the schema and schema_version/schema_description come from
86             the prototype file, its params supply knob defaults, and the other
87             creation switches override those params.
88              
89             The input format matches `iforest fit`: CSV, all columns numeric
90             features, one sample per row.
91              
92             Output format is one line per input row.
93              
94             $score,$label
95              
96             If -d is specified all input feature columns are prepended.
97              
98             $feat1,...,$featN,$score,$label
99              
100             Switches to new args for new models are like below...
101              
102             -n -> n_trees
103             --window -> window_size
104             --eta -> max_leaf_samples
105             --growth -> growth
106             --subsample -> subsample
107             -s -> seed
108             -c -> contamination
109             -t -> feature_names
110             ';
111             } ## end sub description
112              
113             sub validate {
114 10     10 0 26 my ( $self, $opt, $args ) = @_;
115              
116 10 50       372 if ( !defined( $opt->{'i'} ) ) {
    50          
    50          
117 0         0 $self->usage_error('-i has not been specified for a file to process');
118             } elsif ( !-f $opt->{'i'} ) {
119 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file or does not exist' );
120             } elsif ( !-r $opt->{'i'} ) {
121 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
122             }
123              
124 10 50 66     317 if ( -e $opt->{'m'} && !-r $opt->{'m'} ) {
125 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", exists but is not readable' );
126             }
127              
128 10 50 66     55 if ( $opt->{'learn_only'} && $opt->{'score_only'} ) {
129 0         0 $self->usage_error('--learn-only and --score-only may not be combined');
130             }
131              
132 10 50 66     82 if ( defined( $opt->{'o'} ) && !$opt->{'w'} && -e $opt->{'o'} ) {
      66        
133 0         0 $self->usage_error( '-o, "' . $opt->{'o'} . '", already exists and -w is not specified' );
134             }
135              
136 10 0 0     46 if ( defined( $opt->{'threshold'} ) && ( $opt->{'threshold'} <= 0 || $opt->{'threshold'} >= 1 ) ) {
      33        
137 0         0 $self->usage_error( '--threshold, "' . $opt->{'threshold'} . '", needs to be greater than 0 and less than 1' );
138             }
139              
140 10 50 33     36 if ( defined( $opt->{'growth'} ) && $opt->{'growth'} !~ /\A(?:adaptive|fixed)\z/ ) {
141 0         0 $self->usage_error( '--growth, "' . $opt->{'growth'} . '", must be either adaptive or fixed' );
142             }
143              
144 10 100       32 if ( defined( $opt->{'mungers'} ) ) {
145 1 50       19 if ( !-f $opt->{'mungers'} ) {
    50          
    50          
146 0         0 $self->usage_error( '--mungers, "' . $opt->{'mungers'} . '", is not a file or does not exist' );
147             } elsif ( !-r $opt->{'mungers'} ) {
148 0         0 $self->usage_error( '--mungers, "' . $opt->{'mungers'} . '", is not readable' );
149             } elsif ( !defined( $opt->{'t'} ) ) {
150 0         0 $self->usage_error('--mungers requires feature tags (-t) to compile against');
151             }
152             }
153              
154 10 100       33 if ( defined( $opt->{'prototype'} ) ) {
155 3 50       59 if ( !-f $opt->{'prototype'} ) {
    50          
156 0         0 $self->usage_error( '--prototype, "' . $opt->{'prototype'} . '", is not a file or does not exist' );
157             } elsif ( !-r $opt->{'prototype'} ) {
158 0         0 $self->usage_error( '--prototype, "' . $opt->{'prototype'} . '", is not readable' );
159             }
160 3 50 33     25 if ( defined( $opt->{'t'} ) || defined( $opt->{'mungers'} ) ) {
161 0         0 $self->usage_error(
162             '--prototype may not be combined with -t or --mungers; the schema comes only from the prototype');
163             }
164             } ## end if ( defined( $opt->{'prototype'} ) )
165              
166 10         39 return 1;
167             } ## end sub validate
168              
169             sub execute {
170 10     10 1 69 my ( $self, $opt, $args ) = @_;
171              
172             # --- resume an existing model first ------------------------------------
173             # Loaded before the CSV is read because a munger-bearing model changes
174             # how the CSV is validated (munged columns hold raw values).
175 10         19 my $oif;
176 10 100       90 if ( -f $opt->{'m'} ) {
177 6         64 $oif = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
178 6 100       287 die( '-m, "' . $opt->{'m'} . '", is not an online model; stream only works on those' . "\n" )
179             unless ref $oif eq 'Algorithm::Classifier::IsolationForest::Online';
180             }
181              
182             # Prototype creation, new models only like the other creation knobs.
183             # Done before the CSV is read for the same reason resuming is: a
184             # munger-bearing prototype changes how the CSV is validated. The
185             # explicit creation switches override the prototype's params.
186 9         21 my $from_proto = 0;
187 9 100 100     51 if ( !$oif && defined( $opt->{'prototype'} ) ) {
188 2         5 my $proto = eval {
189 2         18 Algorithm::Classifier::IsolationForest->validate_prototype( scalar read_file( $opt->{'prototype'} ) );
190             };
191 2 50       8 die( '--prototype, "' . $opt->{'prototype'} . '", is not a valid prototype: ' . $@ ) if $@;
192             die( '--prototype, "' . $opt->{'prototype'} . '", is for a batch model; use `iforest fit`' . "\n" )
193 2 100       201 unless $proto->{class} eq 'online';
194              
195 1         2 my %overrides;
196 1 50       4 $overrides{'n_trees'} = $opt->{'n'} if defined $opt->{'n'};
197 1 50       3 $overrides{'window_size'} = $opt->{'window'} if defined $opt->{'window'};
198 1 50       2 $overrides{'max_leaf_samples'} = $opt->{'eta'} if defined $opt->{'eta'};
199 1 50       4 $overrides{'growth'} = $opt->{'growth'} if defined $opt->{'growth'};
200 1 50       3 $overrides{'subsample'} = $opt->{'subsample'} if defined $opt->{'subsample'};
201 1 50       4 $overrides{'seed'} = $opt->{'s'} if defined $opt->{'s'};
202 1 50       2 $overrides{'contamination'} = $opt->{'c'} if defined $opt->{'c'};
203              
204 1         2 $oif = eval { Algorithm::Classifier::IsolationForest->new_from_prototype( $proto, %overrides ) };
  1         6  
205 1 50       3 die( '--prototype, "' . $opt->{'prototype'} . '", failed to create a model: ' . $@ ) if $@;
206 1         4 $from_proto = 1;
207             } ## end if ( !$oif && defined( $opt->{'prototype'}...))
208              
209             # Munger spec for a NEW model (an existing model carries its own; the
210             # creation knob is ignored then, like the rest of them).
211 8         20 my $mungers;
212 8 100 100     36 if ( !$oif && defined( $opt->{'mungers'} ) ) {
213 1         8 require JSON::PP;
214 1         2 $mungers = eval { JSON::PP->new->decode( scalar read_file( $opt->{'mungers'} ) ) };
  1         9  
215 1 50       978 die( '--mungers, "' . $opt->{'mungers'} . '", did not parse as JSON: ' . $@ ) if $@;
216 1 50       4 die( '--mungers, "' . $opt->{'mungers'} . '", must be a JSON object of tag => spec' )
217             unless ref $mungers eq 'HASH';
218             }
219              
220             my $has_mungers
221             = $oif
222 8 100 66     62 ? ( ref $oif->{mungers} eq 'HASH' && %{ $oif->{mungers} } ? 1 : 0 )
    100          
    100          
223             : ( $mungers ? 1 : 0 );
224              
225             # --- read the CSV, exactly like `iforest fit` does -------------------
226 8         21 my @data;
227             my $expected_cols;
228              
229 8         17 my $line_int = 1;
230 8         85 foreach my $line ( read_file( $opt->{'i'} ) ) {
231 752         2970 chomp($line);
232 752 50       1266 next if $line =~ /^\s*$/;
233              
234 752         1374 my @fields = split( /,/, $line, -1 );
235              
236 752 100       1276 if ( !defined($expected_cols) ) {
    50          
237 8         18 $expected_cols = scalar @fields;
238 8 50       27 die( 'Line ' . $line_int . ' of "' . $opt->{'i'} . '" has no columns' )
239             if $expected_cols < 1;
240             } elsif ( scalar @fields != $expected_cols ) {
241             die( 'Line '
242             . $line_int . ' of "'
243 0         0 . $opt->{'i'}
244             . '" has '
245             . scalar(@fields)
246             . ' columns but expected '
247             . $expected_cols );
248             }
249              
250 752 100       1046 if ( !$has_mungers ) {
251 590         670 my $col_int = 1;
252 590         711 for my $field (@fields) {
253             die( 'Line '
254             . $line_int . ' of "'
255 1770 50       2745 . $opt->{'i'}
256             . '" value for column '
257             . $col_int . ',"'
258             . $field
259             . '", does not appear to be a number' )
260             unless looks_like_number($field);
261 1770         2100 $col_int++;
262             } ## end for my $field (@fields)
263             } ## end if ( !$has_mungers )
264              
265 752         931 push @data, \@fields;
266              
267 752         925 $line_int++;
268             } ## end foreach my $line ( read_file( $opt->{'i'} ) )
269              
270             # A prototype-created model already carries its tags; hold them to the
271             # same CSV-width check the -t path gets.
272 8 100       73 if ($from_proto) {
273 1         1 my $n_tags = scalar @{ $oif->feature_names };
  1         5  
274 1 50       4 my $n_features = defined($expected_cols) ? $expected_cols : 0;
275 1 50       14 die( 'Number of prototype feature_names ('
276             . $n_tags
277             . ') does not match number of CSV columns ('
278             . $n_features
279             . ')' )
280             unless $n_tags == $n_features;
281             } ## end if ($from_proto)
282              
283             # --- create the model when not resuming --------------------------------
284 8 100       29 if ( !$oif ) {
285 2 100       17 if ( defined( $opt->{'t'} ) ) {
286 1         2 my $n_tags = scalar @{ $opt->{'t'} };
  1         3  
287 1 50       3 my $n_features = defined($expected_cols) ? $expected_cols : 0;
288 1 50       3 die( 'Number of feature tags (' . $n_tags . ') does not match number of CSV columns (' . $n_features . ')' )
289             unless $n_tags == $n_features;
290             }
291             $oif = Algorithm::Classifier::IsolationForest::Online->new(
292             'n_trees' => $opt->{'n'},
293             'window_size' => $opt->{'window'},
294             'max_leaf_samples' => $opt->{'eta'},
295             'growth' => $opt->{'growth'},
296             'subsample' => $opt->{'subsample'},
297             'seed' => $opt->{'s'},
298             'contamination' => $opt->{'c'},
299 2         31 'feature_names' => $opt->{'t'},
300             'mungers' => $mungers,
301             );
302             } ## end if ( !$oif )
303              
304             # Munge the raw rows into numbers, then run the numeric validation
305             # that was skipped at read time. Munged into a separate structure so
306             # -d still prints the raw input columns as given.
307 8         25 my $stream_rows = \@data;
308 8 100       99 if ($has_mungers) {
309 2         12 my $munged = $oif->munge_rows( \@data );
310 2         7 for my $i ( 0 .. $#$munged ) {
311 162         181 for my $col ( 0 .. $#{ $munged->[$i] } ) {
  162         242  
312             die( 'Line '
313             . ( $i + 1 ) . ' of "'
314 486 0       798 . $opt->{'i'}
    50          
315             . '" value for column '
316             . ( $col + 1 ) . ',"'
317             . ( defined $munged->[$i][$col] ? $munged->[$i][$col] : 'undef' )
318             . '", is not a number after munging' )
319             unless looks_like_number( $munged->[$i][$col] );
320             } ## end for my $col ( 0 .. $#{ $munged->[$i] } )
321             } ## end for my $i ( 0 .. $#$munged )
322 2         5 $stream_rows = $munged;
323             } ## end if ($has_mungers)
324              
325             # --- stream ------------------------------------------------------------
326 8         21 my $results_string = '';
327 8 100       31 if ( $opt->{'learn_only'} ) {
328 1         7 $oif->learn($stream_rows);
329             } else {
330 7         14 my $scores;
331 7 100       29 if ( $opt->{'score_only'} ) {
332 1         18 $scores = $oif->score_samples($stream_rows);
333             } else {
334 6         34 $scores = $oif->score_learn($stream_rows);
335             }
336              
337             my $threshold
338 7 50       60 = defined $opt->{'threshold'} ? $opt->{'threshold'}
    50          
339             : defined $oif->decision_threshold ? $oif->decision_threshold
340             : 0.5;
341              
342 7         30 for my $i ( 0 .. $#$scores ) {
343 631 100       904 my $label = $scores->[$i] >= $threshold ? 1 : 0;
344 631 50       855 if ( $opt->{'d'} ) {
345 0         0 $results_string .= join( ',', @{ $data[$i] } ) . ',' . $scores->[$i] . ',' . $label . "\n";
  0         0  
346             } else {
347 631         1504 $results_string .= $scores->[$i] . ',' . $label . "\n";
348             }
349             }
350             } ## end else [ if ( $opt->{'learn_only'} ) ]
351              
352             # Refresh the contamination threshold against the post-stream window so
353             # the saved model's default cutoff tracks the stream.
354 8 50 66     71 if ( !$opt->{'score_only'} && defined $oif->{contamination} && $oif->window_count ) {
      33        
355 0         0 $oif->relearn_threshold;
356             }
357              
358 8 100 66     50 if ( $opt->{'save'} && !$opt->{'score_only'} ) {
359 7         43 $oif->save( $opt->{'m'} );
360             }
361              
362 8 100       129432 if ( length $results_string ) {
363 7 100       38 if ( !defined( $opt->{'o'} ) ) {
364 6         92 print $results_string;
365             } else {
366 1         9 write_file( $opt->{'o'}, { 'atomic' => 1 }, $results_string );
367             }
368             }
369              
370 8         3115 return 1;
371             } ## end sub execute
372              
373             return 1;