File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/fit.pm
Criterion Covered Total %
statement 101 116 87.0
branch 68 102 66.6
condition 12 21 57.1
subroutine 9 11 81.8
pod 4 5 80.0
total 194 255 76.0


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::fit;
2              
3 81     81   53691 use strict;
  81         176  
  81         2913  
4 81     81   308 use warnings;
  81         135  
  81         3050  
5 81     81   345 use Algorithm::Classifier::IsolationForest ();
  81         119  
  81         2106  
6 81     81   286 use Algorithm::Classifier::IsolationForest::App -command;
  81         121  
  81         701  
7 81     81   22806 use File::Slurp qw(read_file write_file);
  81         163  
  81         4504  
8 81     81   399 use Scalar::Util qw(looks_like_number);
  81         190  
  81         119438  
9              
10             sub opt_spec {
11             return (
12 12     12 1 311883 [ 'i=s', 'CSV to use.', { completion => 'files' } ],
13             [ 'o=s', 'Output JSON file path/name.', { 'default' => 'iforest_model.json', 'completion' => 'files' } ],
14             [ 'p', 'Print the results instead of saving it.' ],
15             [ 'w', 'Overwrite the file if it already exists.' ],
16             [ 's=i', 'Seed int' ],
17             [ 'extended', 'Use EIF instead of IF.' ],
18             [ 'n=i', 'Number of isolation trees in the ensemble' ],
19             [ 'm=i', 'Sub-sample size used to build each tree... max samples' ],
20             [ 'd=i', 'per-tree height limit... if not defined is set to ceil(log2(psi))' ],
21             [
22             'e=f',
23             'How many features take partin each split. 0 behaves like a single-feature (axis) cut; the maximum (n_features - 1) uses every varying feature. undef => maximum. Clamped to [0, n_features - 1] at fit time. May only be used with -e.'
24             ],
25             [
26             'c=f',
27             'Contamination. Expected fraction of anomalies, in (0, 0.5]. When given, fit() learns a score threshold that flags this fraction of the training set, and predict() uses it by default. undef => no learned threshold (predict() falls back to 0.5).'
28             ],
29             [
30             't=s@',
31             '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.'
32             ],
33             [
34             'voting=s',
35             "Scoring-time aggregation: 'mean' (classic averaged score, the default) or 'majority' (MVIForest: each tree votes against the decision threshold and the label is the majority vote).",
36             ],
37             [
38             'mungers=s',
39             'JSON file of Algorithm::ToNumberMunger specs, keyed by feature tag. Requires -t. '
40             . 'Munged CSV columns may hold raw (non-numeric) values; they are munged before fitting '
41             . 'and the spec is saved with the model. Scalar mungers only (no into/from lists) for CSV input.',
42             { 'completion' => 'files' }
43             ],
44             [
45             'prototype=s',
46             'JSON prototype file to create the model from: the variable schema (feature names, '
47             . 'descriptions, mungers, missing policy) plus schema_version/schema_description come from it, '
48             . 'and its params supply knob defaults that explicit switches override. May not be combined '
49             . 'with -t or --mungers (the schema is the prototype\'s). See PROTOTYPES in the module POD.',
50             { 'completion' => 'files' }
51             ],
52             );
53             } ## end sub opt_spec
54              
55 0     0 1 0 sub abstract { 'Fits the model using the specified data and save it' }
56              
57             sub description {
58 0     0 1 0 'Fits the model using the specified data and save it
59              
60             The input format is expected to be CSV. All columns are used as features;
61             each row becomes one sample. Every row must have the same number of columns
62             and every value must be numeric.
63              
64             Switches to new args are like below...
65              
66             -n -> n_trees
67             -s -> seed
68             -m -> sample_size
69             -e -> extension_level
70             -c -> contamination
71             --voting -> voting
72              
73             With --prototype the schema (feature names, descriptions, mungers,
74             missing policy) and schema_version/schema_description come from the
75             prototype file, its params supply knob defaults, and the switches above
76             override those params. See PROTOTYPES in the module POD for the format.
77              
78             ';
79             } ## end sub description
80              
81             sub validate {
82 12     12 0 32 my ( $self, $opt, $args ) = @_;
83              
84 12 50       470 if ( !defined( $opt->{'i'} ) ) {
    50          
    50          
85 0         0 $self->usage_error('-i has not been specified');
86             } elsif ( !-f $opt->{'i'} ) {
87 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file' );
88             } elsif ( !-r $opt->{'i'} ) {
89 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
90             }
91              
92 12 50 66     124 if ( defined( $opt->{'s'} ) && $opt->{'s'} <= 0 ) {
93 0         0 $self->usage_error( '-s, "' . $opt->{'s'} . '", is less than or equal to 0, should be a positive int' );
94             }
95              
96 12 50 33     83 if ( !defined( $opt->{'extended'} ) && defined( $opt->{'e'} ) ) {
97 0         0 $self->usage_error('-e may not be used without --extended');
98             }
99              
100 12 100       44 if ( !$opt->{'p'} ) {
101 8 50 66     302 if ( -e $opt->{'o'} && !$opt->{'w'} ) {
102 0         0 $self->usage_error( '-o,"' . $opt->{'o'} . '", already exists and -w was not specified' );
103             }
104             }
105              
106 12 50 33     49 if ( defined( $opt->{'e'} ) && $opt->{'e'} < 0 ) {
107 0         0 $self->usage_error( '-e, "' . $opt->{'e'} . '", is less than 0... should be a float greater or equal to 0' );
108             }
109              
110 12 100 100     67 if ( defined( $opt->{'voting'} ) && $opt->{'voting'} !~ /\A(?:mean|majority)\z/ ) {
111 1         8 $self->usage_error( '--voting, "' . $opt->{'voting'} . '", must be either mean or majority' );
112             }
113              
114 11 100       31 if ( defined( $opt->{'mungers'} ) ) {
115 2 50       36 if ( !-f $opt->{'mungers'} ) {
    50          
    100          
116 0         0 $self->usage_error( '--mungers, "' . $opt->{'mungers'} . '", is not a file or does not exist' );
117             } elsif ( !-r $opt->{'mungers'} ) {
118 0         0 $self->usage_error( '--mungers, "' . $opt->{'mungers'} . '", is not readable' );
119             } elsif ( !defined( $opt->{'t'} ) ) {
120 1         10 $self->usage_error('--mungers requires feature tags (-t) to compile against');
121             }
122             }
123              
124 10 100       29 if ( defined( $opt->{'prototype'} ) ) {
125 4 50       74 if ( !-f $opt->{'prototype'} ) {
    50          
126 0         0 $self->usage_error( '--prototype, "' . $opt->{'prototype'} . '", is not a file or does not exist' );
127             } elsif ( !-r $opt->{'prototype'} ) {
128 0         0 $self->usage_error( '--prototype, "' . $opt->{'prototype'} . '", is not readable' );
129             }
130 4 100 66     22 if ( defined( $opt->{'t'} ) || defined( $opt->{'mungers'} ) ) {
131 1         8 $self->usage_error(
132             '--prototype may not be combined with -t or --mungers; the schema comes only from the prototype');
133             }
134             } ## end if ( defined( $opt->{'prototype'} ) )
135              
136 9         30 return 1;
137             } ## end sub validate
138              
139             sub execute {
140 9     9 1 74 my ( $self, $opt, $args ) = @_;
141              
142 9         22 my $mode = 'axis';
143 9 50       30 if ( $opt->{'extended'} ) {
144 0         0 $mode = 'extended';
145             }
146              
147             # Munger spec, decoded up front so a bad file dies before the CSV is
148             # read. With mungers active the per-field numeric check is skipped
149             # during the read (munged columns legitimately hold raw strings) and
150             # re-run after munging instead.
151 9         36 my $mungers;
152 9 100       28 if ( defined( $opt->{'mungers'} ) ) {
153 1         8 require JSON::PP;
154 1         3 $mungers = eval { JSON::PP->new->decode( scalar read_file( $opt->{'mungers'} ) ) };
  1         10  
155 1 50       1022 die( '--mungers, "' . $opt->{'mungers'} . '", did not parse as JSON: ' . $@ ) if $@;
156 1 50       4 die( '--mungers, "' . $opt->{'mungers'} . '", must be a JSON object of tag => spec' )
157             unless ref $mungers eq 'HASH';
158             }
159              
160             # A prototype supplies the schema and knob defaults, so the model is
161             # created before the CSV is read (a munger-bearing prototype changes
162             # how the CSV is validated). Explicit tuning switches override the
163             # prototype's params; the schema may not be overridden at all.
164 9         32 my $iforest;
165 9 100       31 if ( defined( $opt->{'prototype'} ) ) {
166 3         7 my $proto = eval {
167 3         22 Algorithm::Classifier::IsolationForest->validate_prototype( scalar read_file( $opt->{'prototype'} ) );
168             };
169 3 50       10 die( '--prototype, "' . $opt->{'prototype'} . '", is not a valid prototype: ' . $@ ) if $@;
170             die( '--prototype, "' . $opt->{'prototype'} . '", is for an online model; use `iforest stream`' . "\n" )
171 3 100       207 unless $proto->{class} eq 'batch';
172              
173 2         4 my %overrides;
174 2 50       7 $overrides{'n_trees'} = $opt->{'n'} if defined $opt->{'n'};
175 2 50       7 $overrides{'seed'} = $opt->{'s'} if defined $opt->{'s'};
176 2 50       5 $overrides{'sample_size'} = $opt->{'m'} if defined $opt->{'m'};
177 2 50       8 $overrides{'max_depth'} = $opt->{'d'} if defined $opt->{'d'};
178 2 50       6 $overrides{'mode'} = 'extended' if $opt->{'extended'};
179 2 50       6 $overrides{'extension_level'} = $opt->{'e'} if defined $opt->{'e'};
180 2 50       5 $overrides{'contamination'} = $opt->{'c'} if defined $opt->{'c'};
181 2 50       5 $overrides{'voting'} = $opt->{'voting'} if defined $opt->{'voting'};
182              
183 2         5 $iforest = eval { Algorithm::Classifier::IsolationForest->new_from_prototype( $proto, %overrides ) };
  2         8  
184 2 50       13 die( '--prototype, "' . $opt->{'prototype'} . '", failed to create a model: ' . $@ ) if $@;
185             } ## end if ( defined( $opt->{'prototype'} ) )
186              
187             my $has_mungers
188             = $mungers ? 1
189 8 50 33     79 : ( $iforest && ref $iforest->{mungers} eq 'HASH' && %{ $iforest->{mungers} } ) ? 1
    100          
190             : 0;
191              
192 8         17 my @data;
193             my $expected_cols;
194              
195 8         14 my $line_int = 1;
196 8         51 foreach my $line ( read_file( $opt->{'i'} ) ) {
197 482         2093 chomp($line);
198 482 50       923 next if $line =~ /^\s*$/;
199              
200 482         907 my @fields = split( /,/, $line, -1 );
201              
202 482 100       853 if ( !defined($expected_cols) ) {
    50          
203 8         16 $expected_cols = scalar @fields;
204 8 50       27 die( 'Line ' . $line_int . ' of "' . $opt->{'i'} . '" has no columns' )
205             if $expected_cols < 1;
206             } elsif ( scalar @fields != $expected_cols ) {
207             die( 'Line '
208             . $line_int . ' of "'
209 0         0 . $opt->{'i'}
210             . '" has '
211             . scalar(@fields)
212             . ' columns but expected '
213             . $expected_cols );
214             }
215              
216 482 100       648 if ( !$has_mungers ) {
217 401         472 my $col_int = 1;
218 401         507 for my $field (@fields) {
219             die( 'Line '
220             . $line_int . ' of "'
221 1203 50       1895 . $opt->{'i'}
222             . '" value for column '
223             . $col_int . ',"'
224             . $field
225             . '", does not appear to be a number' )
226             unless looks_like_number($field);
227 1203         1425 $col_int++;
228             } ## end for my $field (@fields)
229             } ## end if ( !$has_mungers )
230              
231 482         650 push @data, \@fields;
232              
233 482         595 $line_int++;
234             } ## end foreach my $line ( read_file( $opt->{'i'} ) )
235              
236             # The tag count must match the CSV width whether the tags came from -t
237             # or from a prototype's schema.
238 8 100       151 my $check_tags = defined( $opt->{'t'} ) ? $opt->{'t'} : ( $iforest ? $iforest->feature_names : undef );
    100          
239 8 100       33 if ( defined($check_tags) ) {
240 4         10 my $n_tags = scalar @$check_tags;
241 4 50       10 my $n_features = defined($expected_cols) ? $expected_cols : 0;
242 4 50       13 die( 'Number of feature tags (' . $n_tags . ') does not match number of CSV columns (' . $n_features . ')' )
243             unless $n_tags == $n_features;
244             }
245              
246 8 100       25 if ( !$iforest ) {
247             $iforest = Algorithm::Classifier::IsolationForest->new(
248             'mode' => $mode,
249             'n_trees' => $opt->{'n'},
250             'seed' => $opt->{'s'},
251             'sample_size' => $opt->{'m'},
252             'max_depth' => $opt->{'d'},
253             'extension_level' => $opt->{'e'},
254             'contamination' => $opt->{'c'},
255             'feature_names' => $opt->{'t'},
256 6         112 'voting' => $opt->{'voting'},
257             'mungers' => $mungers,
258             );
259             } ## end if ( !$iforest )
260              
261             # Munge the raw rows into numbers, then run the numeric validation
262             # that was skipped at read time -- an unmunged column holding a
263             # string is still an error, just reported post-munge.
264 8 100       37 if ($has_mungers) {
265 1         5 my $munged = $iforest->munge_rows( \@data );
266 1         5 for my $i ( 0 .. $#$munged ) {
267 81         83 for my $col ( 0 .. $#{ $munged->[$i] } ) {
  81         91  
268             die( 'Line '
269             . ( $i + 1 ) . ' of "'
270 243 0       426 . $opt->{'i'}
    50          
271             . '" value for column '
272             . ( $col + 1 ) . ',"'
273             . ( defined $munged->[$i][$col] ? $munged->[$i][$col] : 'undef' )
274             . '", is not a number after munging' )
275             unless looks_like_number( $munged->[$i][$col] );
276             } ## end for my $col ( 0 .. $#{ $munged->[$i] } )
277             } ## end for my $i ( 0 .. $#$munged )
278 1         22 @data = @$munged;
279             } ## end if ($has_mungers)
280              
281 8         55 $iforest->fit( \@data );
282              
283 8         60 my $model = $iforest->to_json;
284              
285 8 100       426818 if ( $opt->{'p'} ) {
286 1         65 print $model. "\n";
287 1         599 exit 0;
288             }
289              
290 7         80 write_file( $opt->{'o'}, { 'atomic' => 1 }, $model );
291             } ## end sub execute
292              
293             return 1;