File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/predict.pm
Criterion Covered Total %
statement 66 77 85.7
branch 23 36 63.8
condition 6 12 50.0
subroutine 10 12 83.3
pod 4 5 80.0
total 109 142 76.7


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::predict;
2              
3 22     22   13646 use strict;
  22         39  
  22         668  
4 22     22   82 use warnings;
  22         33  
  22         736  
5 22     22   84 use Algorithm::Classifier::IsolationForest ();
  22         33  
  22         404  
6 22     22   88 use Algorithm::Classifier::IsolationForest::App -command;
  22         28  
  22         158  
7 22     22   6694 use Algorithm::Classifier::IsolationForest::App::Command::pack ();
  22         42  
  22         519  
8 22     22   73 use File::Slurp qw(read_file write_file);
  22         34  
  22         1269  
9 22     22   95 use Scalar::Util qw(looks_like_number);
  22         90  
  22         18761  
10              
11             sub opt_spec {
12             return (
13             [
14 3     3 1 74292 '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 3     3 0 8 my ( $self, $opt, $args ) = @_;
53              
54 3 50       105 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 3 50       47 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 3 50 66     93 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 3 50 33     24 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 3         10 return 1;
79             } ## end sub validate
80              
81             sub execute {
82 3     3 1 19 my ( $self, $opt, $args ) = @_;
83              
84 3         28 my $iforest = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
85              
86 3         8 my @data; # arrayref-of-arrayrefs OR re-derived on demand from $packed
87             my $score_input; # what we hand to score_predict_samples
88              
89 3 100       41 if ( Algorithm::Classifier::IsolationForest::App::Command::pack::is_packed_file( $opt->{'i'} ) ) {
90             my ( $n_pts, $n_feats, $bytes )
91 2         9 = Algorithm::Classifier::IsolationForest::App::Command::pack::read_packed_file( $opt->{'i'} );
92             die "packed input has $n_feats features but model expects " . $iforest->{n_features} . "\n"
93 2 50       12 if $n_feats != $iforest->{n_features};
94              
95             # Build a PackedData wrapper directly from the on-disk bytes --
96             # no CSV parse, no pack_input_xs.
97 2         33 $score_input = bless {
98             packed => $bytes,
99             n_pts => $n_pts,
100             n_feats => $n_feats,
101             },
102             'Algorithm::Classifier::IsolationForest::PackedData';
103              
104             # Only unpack to per-row arrayrefs when -d asks for it, since
105             # that work undoes the whole point of using a packed file.
106 2 100       8 if ( $opt->{'d'} ) {
107 1         5 my @doubles = unpack( 'd*', $bytes );
108 1         4 for my $i ( 0 .. $n_pts - 1 ) {
109 7         18 push @data, [ @doubles[ $i * $n_feats .. ( $i + 1 ) * $n_feats - 1 ] ];
110             }
111             }
112             } else {
113             # CSV path
114 1         2 my $expected_cols;
115 1         2 my $line_int = 1;
116 1         9 foreach my $line ( read_file( $opt->{'i'} ) ) {
117 7         182 chomp($line);
118 7 50       17 next if $line =~ /^\s*$/;
119              
120 7         13 my @fields = split( /,/, $line, -1 );
121              
122 7 100       15 if ( !defined($expected_cols) ) {
    50          
123 1         2 $expected_cols = scalar @fields;
124 1 50       4 die( 'Line ' . $line_int . ' of "' . $opt->{'i'} . '" has no columns' )
125             if $expected_cols < 1;
126             } elsif ( scalar @fields != $expected_cols ) {
127             die( 'Line '
128             . $line_int . ' of "'
129 0         0 . $opt->{'i'}
130             . '" has '
131             . scalar(@fields)
132             . ' columns but expected '
133             . $expected_cols );
134             }
135              
136 7         9 my $col_int = 1;
137 7         10 for my $field (@fields) {
138             die( 'Line '
139             . $line_int . ' of "'
140 21 50       46 . $opt->{'i'}
141             . '" value for column '
142             . $col_int . ',"'
143             . $field
144             . '", does not appear to be a number' )
145             unless looks_like_number($field);
146 21         27 $col_int++;
147             } ## end for my $field (@fields)
148              
149 7         10 push @data, \@fields;
150              
151 7         8 $line_int++;
152             } ## end foreach my $line ( read_file( $opt->{'i'} ) )
153 1         2 $score_input = \@data;
154             } ## end else [ if ( Algorithm::Classifier::IsolationForest::App::Command::pack::is_packed_file...)]
155              
156 3         24 my $results = $iforest->score_predict_samples( $score_input, $opt->{'t'} );
157              
158 3         14 my $results_string = '';
159              
160             # Drive the loop off $results rather than @data so the packed-input
161             # path (which only populates @data when -d is set) still produces
162             # one output row per scored point.
163 3         14 for my $i ( 0 .. $#$results ) {
164 21 100       46 if ( $opt->{'d'} ) {
165 7         10 $results_string .= join( ',', @{ $data[$i] } ) . ',' . $results->[$i][0] . ',' . $results->[$i][1] . "\n";
  7         61  
166             } else {
167 14         69 $results_string .= $results->[$i][0] . ',' . $results->[$i][1] . "\n";
168             }
169             }
170              
171 3 100       15 if ( !defined( $opt->{'o'} ) ) {
172 1         8 print $results_string;
173 1         267 exit 0;
174             }
175              
176 2         28 write_file( $opt->{'o'}, { 'atomic' => 1 }, $results_string );
177             } ## end sub execute
178              
179             return 1;