File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/set_voting.pm
Criterion Covered Total %
statement 55 64 85.9
branch 24 36 66.6
condition 7 12 58.3
subroutine 9 11 81.8
pod 4 5 80.0
total 99 128 77.3


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::set_voting;
2              
3 81     81   52070 use strict;
  81         153  
  81         2658  
4 81     81   306 use warnings;
  81         170  
  81         2946  
5 81     81   353 use Algorithm::Classifier::IsolationForest ();
  81         158  
  81         1630  
6 81     81   283 use Algorithm::Classifier::IsolationForest::App -command;
  81         128  
  81         592  
7 81     81   24630 use File::Slurp qw(read_file write_file);
  81         175  
  81         4828  
8 81     81   392 use Scalar::Util qw(looks_like_number);
  81         165  
  81         73179  
9              
10             sub opt_spec {
11             return (
12             [
13 4     4 1 100049 'm=s',
14             'Input model JSON file path/name.',
15             { 'default' => 'iforest_model.json', 'completion' => 'files' }
16             ],
17             [
18             'voting=s',
19             "Target scoring-time aggregation: 'mean' (classic averaged score) or 'majority' (MVIForest per-tree vote)."
20             ],
21             [
22             'i=s',
23             'CSV training data. Required only when the model was fit with contamination, so the decision threshold can be recalibrated for the new mode.',
24             { 'completion' => 'files' }
25             ],
26             [ 'o=s', 'Write the updated model here instead of overwriting -m.', { 'completion' => 'files' } ],
27             [ 'p', 'Print the updated model JSON instead of saving it.' ],
28             [ 'w', 'Overwrite the -o file if it already exists.' ],
29             );
30             } ## end sub opt_spec
31              
32 0     0 1 0 sub abstract { 'Switch a saved model between mean and majority voting' }
33              
34             sub description {
35 0     0 1 0 'Switches the scoring-time aggregation of a saved model between "mean" and
36             "majority" and writes it back (in place over -m by default, or to -o / stdout).
37              
38             The forest itself is voting-independent, so no tree is rebuilt. The one thing
39             that does not carry over is a contamination-learned decision threshold: it is a
40             quantile of whichever per-point quantity the mode thresholds against (the
41             averaged anomaly score under mean, the per-tree majority pivot under majority),
42             so switching relearns it for the target mode. That recalibration needs the
43             original training data, supplied as a CSV via -i. Models fit without
44             contamination carry no threshold and switch without -i.
45              
46             Switches to new args are like below...
47              
48             --voting -> voting
49             -i -> training CSV (contamination models only)
50              
51             ';
52             } ## end sub description
53              
54             sub validate {
55 4     4 0 11 my ( $self, $opt, $args ) = @_;
56              
57 4 50       131 if ( !-f $opt->{'m'} ) {
    50          
58 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not a file or does not exist' );
59             } elsif ( !-r $opt->{'m'} ) {
60 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not readable' );
61             }
62              
63 4 50       32 if ( !defined( $opt->{'voting'} ) ) {
    100          
64 0         0 $self->usage_error('--voting has not been specified');
65             } elsif ( $opt->{'voting'} !~ /\A(?:mean|majority)\z/ ) {
66 1         8 $self->usage_error( '--voting, "' . $opt->{'voting'} . '", must be either mean or majority' );
67             }
68              
69 3 100       16 if ( defined( $opt->{'i'} ) ) {
70 1 50       16 if ( !-f $opt->{'i'} ) {
    50          
71 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file or does not exist' );
72             } elsif ( !-r $opt->{'i'} ) {
73 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
74             }
75             }
76              
77 3 0 33     16 if ( defined( $opt->{'o'} ) && !$opt->{'w'} && -e $opt->{'o'} ) {
      33        
78 0         0 $self->usage_error( '-o, "' . $opt->{'o'} . '", already exists and -w is not specified' );
79             }
80              
81 3         9 return 1;
82             } ## end sub validate
83              
84             sub execute {
85 3     3 1 19 my ( $self, $opt, $args ) = @_;
86              
87 3         26 my $iforest = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
88              
89             # A contamination-fitted model needs its training data to relearn the
90             # threshold for the target mode; without it set_voting would croak, so
91             # surface the requirement as a friendly usage error pointing at -i. Only
92             # an actual mode change triggers this -- a no-op switch never recalibrates.
93 3         18 my $changing = $iforest->{voting} ne $opt->{'voting'};
94 3 100 66     59 if ( $changing && defined( $iforest->{contamination} ) && !defined( $opt->{'i'} ) ) {
      100        
95             $self->usage_error( 'model "'
96             . $opt->{'m'}
97             . '" was fit with contamination; -i CSV training data is required to recalibrate the threshold for --voting '
98 1         27 . $opt->{'voting'} );
99             }
100              
101 2         5 my @data;
102 2 100       10 if ( defined( $opt->{'i'} ) ) {
103 1         3 my $expected_cols;
104 1         2 my $line_int = 1;
105 1         9 foreach my $line ( read_file( $opt->{'i'} ) ) {
106 63         421 chomp($line);
107 63 50       114 next if $line =~ /^\s*$/;
108              
109 63         154 my @fields = split( /,/, $line, -1 );
110              
111 63 100       115 if ( !defined($expected_cols) ) {
    50          
112 1         2 $expected_cols = scalar @fields;
113 1 50       4 die( 'Line ' . $line_int . ' of "' . $opt->{'i'} . '" has no columns' )
114             if $expected_cols < 1;
115             } elsif ( scalar @fields != $expected_cols ) {
116             die( 'Line '
117             . $line_int . ' of "'
118 0         0 . $opt->{'i'}
119             . '" has '
120             . scalar(@fields)
121             . ' columns but expected '
122             . $expected_cols );
123             }
124              
125 63         72 my $col_int = 1;
126 63         77 for my $field (@fields) {
127             die( 'Line '
128             . $line_int . ' of "'
129 189 50       312 . $opt->{'i'}
130             . '" value for column '
131             . $col_int . ',"'
132             . $field
133             . '", does not appear to be a number' )
134             unless looks_like_number($field);
135 189         226 $col_int++;
136             } ## end for my $field (@fields)
137              
138 63         80 push @data, \@fields;
139              
140 63         94 $line_int++;
141             } ## end foreach my $line ( read_file( $opt->{'i'} ) )
142             } ## end if ( defined( $opt->{'i'} ) )
143              
144             # set_voting ignores the data argument unless it actually recalibrates, so
145             # passing an (undef) empty list for the no-recalibration cases is fine.
146 2 100       26 $iforest->set_voting( $opt->{'voting'}, @data ? \@data : undef );
147              
148 2         17 my $model = $iforest->to_json;
149              
150 2 100       183514 if ( $opt->{'p'} ) {
151 1         181 print $model. "\n";
152 1         486 exit 0;
153             }
154              
155             # Default to writing back over the input model; -o redirects elsewhere.
156 1 50       13 write_file( defined( $opt->{'o'} ) ? $opt->{'o'} : $opt->{'m'}, { 'atomic' => 1 }, $model );
157             } ## end sub execute
158              
159             return 1;