File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/predict.pm
Criterion Covered Total %
statement 75 86 87.2
branch 30 46 65.2
condition 8 15 53.3
subroutine 10 12 83.3
pod 4 5 80.0
total 127 164 77.4


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::predict;
2              
3 81     81   52226 use strict;
  81         166  
  81         2583  
4 81     81   337 use warnings;
  81         157  
  81         2975  
5 81     81   355 use Algorithm::Classifier::IsolationForest ();
  81         131  
  81         1587  
6 81     81   278 use Algorithm::Classifier::IsolationForest::App -command;
  81         120  
  81         571  
7 81     81   21797 use Algorithm::Classifier::IsolationForest::App::Command::pack ();
  81         206  
  81         2082  
8 81     81   304 use File::Slurp qw(read_file write_file);
  81         156  
  81         4752  
9 81     81   412 use Scalar::Util qw(looks_like_number);
  81         133  
  81         82681  
10              
11             sub opt_spec {
12             return (
13             [
14 4     4 1 101722 'm=s',
15             'Input model JSON file path/name.',
16             { 'default' => 'iforest_model.json', 'completion' => 'files' }
17             ],
18             [ 'i=s', 'Input CSV for processing.', { 'completion' => 'files' } ],
19             [ 'o=s', 'Output to this file instead of printing.', { 'completion' => 'files' } ],
20             [ 'w', 'If the file specified via -o exists, over write it.', { 'completion' => 'files' } ],
21             [ 't=f', 'Alternative threshold value to use. 0 < $val < 1' ],
22             [ 'd', 'Include the input data in the output.' ],
23             );
24             } ## end sub opt_spec
25              
26 0     0 1 0 sub abstract { 'Processes the data using the score_predict_samples using the specified model' }
27              
28             sub description {
29 0     0 1 0 'Processes the data using the score_predict_samples using the specified model.
30              
31             The input may be either a CSV (one row of features per line) or a
32             .iforest-packed binary produced by `iforest pack` (auto-detected via
33             its magic bytes; cuts the CSV parse + pack_input_xs cost on repeated
34             runs against the same dataset).
35              
36             The input CSV may have any number of feature columns; every row must have the
37             same column count and every value must be numeric.
38              
39             Output format is as below per line.
40              
41             $score,$predict
42              
43             If -d is specified all input feature columns are prepended. When the
44             input is a .iforest-packed file the columns come from unpacking the
45             stored doubles.
46              
47             $feat1,...,$featN,$score,$predict
48             ';
49             } ## end sub description
50              
51             sub validate {
52 4     4 0 10 my ( $self, $opt, $args ) = @_;
53              
54 4 50       158 if ( !defined( $opt->{'i'} ) ) {
    50          
    50          
55 0         0 $self->usage_error('-i has not been specified for a file to process');
56             } elsif ( !-f $opt->{'i'} ) {
57 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file or does not exist' );
58             } elsif ( !-r $opt->{'i'} ) {
59 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
60             }
61              
62 4 50       77 if ( !-f $opt->{'m'} ) {
    50          
63 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not a file or does not exist' );
64             } elsif ( !-r $opt->{'m'} ) {
65 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not readable' );
66             }
67              
68 4 50 66     99 if ( defined( $opt->{'o'} ) && !$opt->{'w'} && -e $opt->{'o'} ) {
      66        
69 0         0 $self->usage_error( '-o, "' . $opt->{'o'} . '", already exists and -w is not specified' );
70             }
71              
72 4 50 33     31 if ( defined( $opt->{'t'} ) && $opt->{'t'} <= 0 ) {
    50 33        
73 0         0 $self->usage_error( '-t, "' . $opt->{'t'} . '", needs to be greater than 0 and less than 1' );
74             } elsif ( defined( $opt->{'t'} ) && $opt->{'t'} >= 1 ) {
75 0         0 $self->usage_error( '-t, "' . $opt->{'t'} . '", needs to be greater than 0 and less than 1' );
76             }
77              
78 4         11 return 1;
79             } ## end sub validate
80              
81             sub execute {
82 4     4 1 26 my ( $self, $opt, $args ) = @_;
83              
84 4         36 my $iforest = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
85              
86             # A model carrying Algorithm::ToNumberMunger specs takes raw values in
87             # its munged CSV columns: skip the per-field numeric check at read
88             # time and munge the rows before scoring (re-checking numerics after).
89             # Packed input is never munged -- it is already doubles.
90 4 100 66     41 my $has_mungers = ref $iforest->{mungers} eq 'HASH' && %{ $iforest->{mungers} } ? 1 : 0;
91              
92 4         15 my @data; # arrayref-of-arrayrefs OR re-derived on demand from $packed
93             my $score_input; # what we hand to score_predict_samples
94              
95 4 100       42 if ( Algorithm::Classifier::IsolationForest::App::Command::pack::is_packed_file( $opt->{'i'} ) ) {
96             my ( $n_pts, $n_feats, $bytes )
97 2         42 = Algorithm::Classifier::IsolationForest::App::Command::pack::read_packed_file( $opt->{'i'} );
98             die "packed input has $n_feats features but model expects " . $iforest->{n_features} . "\n"
99 2 50       13 if $n_feats != $iforest->{n_features};
100              
101             # Build a PackedData wrapper directly from the on-disk bytes --
102             # no CSV parse, no pack_input_xs.
103 2         36 $score_input = bless {
104             packed => $bytes,
105             n_pts => $n_pts,
106             n_feats => $n_feats,
107             },
108             'Algorithm::Classifier::IsolationForest::PackedData';
109              
110             # Only unpack to per-row arrayrefs when -d asks for it, since
111             # that work undoes the whole point of using a packed file.
112 2 100       30 if ( $opt->{'d'} ) {
113 1         10 my @doubles = unpack( 'd*', $bytes );
114 1         8 for my $i ( 0 .. $n_pts - 1 ) {
115 7         30 push @data, [ @doubles[ $i * $n_feats .. ( $i + 1 ) * $n_feats - 1 ] ];
116             }
117             }
118             } else {
119             # CSV path
120 2         4 my $expected_cols;
121 2         4 my $line_int = 1;
122 2         15 foreach my $line ( read_file( $opt->{'i'} ) ) {
123 88         472 chomp($line);
124 88 50       163 next if $line =~ /^\s*$/;
125              
126 88         170 my @fields = split( /,/, $line, -1 );
127              
128 88 100       181 if ( !defined($expected_cols) ) {
    50          
129 2         5 $expected_cols = scalar @fields;
130 2 50       13 die( 'Line ' . $line_int . ' of "' . $opt->{'i'} . '" has no columns' )
131             if $expected_cols < 1;
132             } elsif ( scalar @fields != $expected_cols ) {
133             die( 'Line '
134             . $line_int . ' of "'
135 0         0 . $opt->{'i'}
136             . '" has '
137             . scalar(@fields)
138             . ' columns but expected '
139             . $expected_cols );
140             }
141              
142 88 100       118 if ( !$has_mungers ) {
143 7         9 my $col_int = 1;
144 7         10 for my $field (@fields) {
145             die( 'Line '
146             . $line_int . ' of "'
147 21 50       37 . $opt->{'i'}
148             . '" value for column '
149             . $col_int . ',"'
150             . $field
151             . '", does not appear to be a number' )
152             unless looks_like_number($field);
153 21         25 $col_int++;
154             } ## end for my $field (@fields)
155             } ## end if ( !$has_mungers )
156              
157 88         137 push @data, \@fields;
158              
159 88         119 $line_int++;
160             } ## end foreach my $line ( read_file( $opt->{'i'} ) )
161 2 100       13 if ($has_mungers) {
162              
163             # Munge into a separate structure so -d still prints the raw
164             # input columns as given.
165 1         8 my $munged = $iforest->munge_rows( \@data );
166 1         4 for my $i ( 0 .. $#$munged ) {
167 81         115 for my $col ( 0 .. $#{ $munged->[$i] } ) {
  81         121  
168             die( 'Line '
169             . ( $i + 1 ) . ' of "'
170 243 0       438 . $opt->{'i'}
    50          
171             . '" value for column '
172             . ( $col + 1 ) . ',"'
173             . ( defined $munged->[$i][$col] ? $munged->[$i][$col] : 'undef' )
174             . '", is not a number after munging' )
175             unless looks_like_number( $munged->[$i][$col] );
176             } ## end for my $col ( 0 .. $#{ $munged->[$i] } )
177             } ## end for my $i ( 0 .. $#$munged )
178 1         4 $score_input = $munged;
179             } else {
180 1         3 $score_input = \@data;
181             }
182             } ## end else [ if ( Algorithm::Classifier::IsolationForest::App::Command::pack::is_packed_file...)]
183              
184 4         47 my $results = $iforest->score_predict_samples( $score_input, $opt->{'t'} );
185              
186 4         26 my $results_string = '';
187              
188             # Drive the loop off $results rather than @data so the packed-input
189             # path (which only populates @data when -d is set) still produces
190             # one output row per scored point.
191 4         26 for my $i ( 0 .. $#$results ) {
192 102 100       272 if ( $opt->{'d'} ) {
193 7         17 $results_string .= join( ',', @{ $data[$i] } ) . ',' . $results->[$i][0] . ',' . $results->[$i][1] . "\n";
  7         99  
194             } else {
195 95         429 $results_string .= $results->[$i][0] . ',' . $results->[$i][1] . "\n";
196             }
197             }
198              
199 4 100       25 if ( !defined( $opt->{'o'} ) ) {
200 2         31 print $results_string;
201 2         987 exit 0;
202             }
203              
204 2         22 write_file( $opt->{'o'}, { 'atomic' => 1 }, $results_string );
205             } ## end sub execute
206              
207             return 1;