File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/pack.pm
Criterion Covered Total %
statement 76 85 89.4
branch 19 40 47.5
condition 3 6 50.0
subroutine 16 18 88.8
pod 4 7 57.1
total 118 156 75.6


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::pack;
2              
3 9     9   5505 use strict;
  9         15  
  9         279  
4 9     9   37 use warnings;
  9         14  
  9         338  
5 9     9   58 use Algorithm::Classifier::IsolationForest ();
  9         18  
  9         157  
6 9     9   27 use Algorithm::Classifier::IsolationForest::App -command;
  9         24  
  9         79  
7 9     9   2574 use File::Slurp qw(read_file write_file);
  9         15  
  9         522  
8 9     9   39 use Scalar::Util qw(looks_like_number);
  9         41  
  9         555  
9              
10             # .iforest-packed v1 file layout (all little-endian):
11             #
12             # offset size field
13             # -----------------------------------------------------------
14             # 0 8 magic -- ASCII "IFPKD\0\0\0"
15             # 8 2 version (uint16, currently 1)
16             # 10 2 reserved (uint16, must be 0)
17             # 12 4 n_pts (uint32)
18             # 16 4 n_feats (uint32)
19             # 20 ... n_pts * n_feats packed doubles ('d' pack format,
20             # little-endian per the IEEE-754 native layout)
21             #
22             # The format is intentionally minimal: the goal is to skip the CSV
23             # parse + pack_input_xs cost on subsequent scoring runs. Models are
24             # not embedded -- the caller must pair the .iforest-packed file with a
25             # model that has the same n_features at score time.
26 9     9   40 use constant MAGIC => 'IFPKD' . "\0\0\0"; # 8 bytes
  9         13  
  9         475  
27 9     9   53 use constant VERSION => 1;
  9         13  
  9         361  
28 9     9   32 use constant HEADER_LEN => 20;
  9         15  
  9         8458  
29              
30             sub _read_packed {
31 2     2   4 my ($path) = @_;
32 2 50       47 open my $fh, '<:raw', $path or die "open '$path' for read: $!\n";
33 2         5 my $hdr;
34 2 50       15 read( $fh, $hdr, HEADER_LEN ) == HEADER_LEN
35             or die "'$path' is shorter than a packed-file header\n";
36 2         12 my ( $magic, $version, $reserved, $n_pts, $n_feats ) = unpack( 'a8 v v V V', $hdr );
37 2 50       6 die "'$path' does not look like a .iforest-packed file\n"
38             unless $magic eq MAGIC;
39 2 50       5 die "'$path' is .iforest-packed version $version; only " . VERSION . " is supported\n"
40             unless $version == VERSION;
41 2 50       12 die "'$path' has non-zero reserved field $reserved\n"
42             unless $reserved == 0;
43 2         3 my $bytes;
44 2         6 my $want = $n_pts * $n_feats * 8;
45 2 0       7 read( $fh, $bytes, $want ) == $want
    50          
46             or die "'$path' truncated: wanted $want bytes, got " . ( defined $bytes ? length($bytes) : 0 ) . "\n";
47 2         13 close $fh;
48 2         12 return ( $n_pts, $n_feats, $bytes );
49             } ## end sub _read_packed
50              
51             sub _write_packed {
52 1     1   3 my ( $path, $n_pts, $n_feats, $bytes ) = @_;
53 1         7 my $hdr = pack( 'a8 v v V V', MAGIC, VERSION, 0, $n_pts, $n_feats );
54 1         7 write_file( $path, { 'atomic' => 1, 'binmode' => ':raw' }, $hdr . $bytes );
55             }
56              
57             # Helper used by the other commands so they all read the same format
58             # the same way. Returns ($n_pts, $n_feats, $bytes_str).
59 2     2 0 8 sub read_packed_file { _read_packed(@_) }
60              
61             # Cheap, allocation-light magic check so consumer commands can peek at
62             # a path without slurping the whole file.
63             sub is_packed_file {
64 3     3 0 37 my ($path) = @_;
65 3 50       321 open my $fh, '<:raw', $path or return 0;
66 3         8 my $magic;
67 3         61 my $ok = read( $fh, $magic, 8 ) == 8;
68 3         43 close $fh;
69 3   66     38 return $ok && $magic eq MAGIC;
70             }
71              
72             sub opt_spec {
73             return (
74             [
75 1     1 1 28102 'm=s',
76             'Model JSON to validate n_features against.',
77             { 'default' => 'iforest_model.json', 'completion' => 'files' }
78             ],
79             [ 'i=s', 'Input CSV to pack.', { 'completion' => 'files' } ],
80             [ 'o=s', 'Output .iforest-packed file path.', { 'completion' => 'files' } ],
81             [ 'w', 'Overwrite -o if it already exists.' ],
82             );
83             } ## end sub opt_spec
84              
85 0     0 1 0 sub abstract { 'Pre-pack a CSV dataset into a binary file the scoring commands can read directly' }
86              
87             sub description {
88 0     0 1 0 'Reads a CSV, validates that every row has the same numeric
89             column count as the model expects, runs the data through pack_data, and
90             writes a self-contained binary (.iforest-packed) the other iforest
91             commands can consume directly.
92              
93             This skips the CSV parse + pack_input_xs cost on subsequent scoring
94             runs. It is most useful when the same data set is scored repeatedly
95             with different thresholds, e.g. during interactive tuning:
96              
97             iforest pack -m model.json -i data.csv -o data.packed
98             iforest predict -m model.json -i data.packed -t 0.55 -o pred-55.csv
99             iforest predict -m model.json -i data.packed -t 0.65 -o pred-65.csv
100             iforest predict -m model.json -i data.packed -t 0.75 -o pred-75.csv
101              
102             The file format begins with the magic bytes "IFPKD\0\0\0". predict
103             auto-detects it on its -i input.
104              
105             Requires the Inline::C backend; pure-Perl installs cannot produce or
106             consume the packed format.
107             ';
108             } ## end sub description
109              
110             sub validate {
111 1     1 0 3 my ( $self, $opt, $args ) = @_;
112              
113 1 50       54 if ( !defined $opt->{'i'} ) {
    50          
    50          
114 0         0 $self->usage_error('-i has not been specified');
115             } elsif ( !-f $opt->{'i'} ) {
116 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file or does not exist' );
117             } elsif ( !-r $opt->{'i'} ) {
118 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
119             }
120              
121 1 50 33     65 if ( !defined $opt->{'o'} ) {
    50          
122 0         0 $self->usage_error('-o has not been specified');
123             } elsif ( -e $opt->{'o'} && !$opt->{'w'} ) {
124 0         0 $self->usage_error( '-o, "' . $opt->{'o'} . '", already exists and -w was not specified' );
125             }
126              
127 1 50       68 if ( !-f $opt->{'m'} ) {
    50          
128 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not a file or does not exist' );
129             } elsif ( !-r $opt->{'m'} ) {
130 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not readable' );
131             }
132              
133 1         7 return 1;
134             } ## end sub validate
135              
136             sub execute {
137 1     1 1 8 my ( $self, $opt, $args ) = @_;
138              
139 1 50       4 die "iforest pack requires the Inline::C backend\n"
140             unless $Algorithm::Classifier::IsolationForest::HAS_C;
141              
142 1         14 my $model = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
143 1         3 my $nf = $model->{n_features};
144              
145 1         2 my @data;
146 1         1 my $line = 0;
147 1         41 for my $row ( read_file( $opt->{'i'} ) ) {
148 7         230 $line++;
149 7         9 chomp $row;
150 7 50       17 next if $row =~ /^\s*$/;
151 7         16 my @f = split /,/, $row, -1;
152 7 50       12 die "line $line of '$opt->{i}' has " . scalar(@f) . " columns but model has $nf features\n"
153             unless scalar @f == $nf;
154 7         9 for my $v (@f) {
155 21 50       38 die "line $line of '$opt->{i}' value '$v' is not numeric\n"
156             unless looks_like_number($v);
157             }
158 7         11 push @data, \@f;
159             } ## end for my $row ( read_file( $opt->{'i'} ) )
160 1 50       8 die "input '$opt->{i}' contains no rows\n" unless @data;
161              
162 1         6 my $packed = $model->pack_data( \@data );
163 1         25 _write_packed( $opt->{'o'}, $packed->n_pts, $packed->n_feats, $packed->{packed} );
164              
165             printf "wrote %s (%d rows, %d features, %d bytes payload)\n",
166 1         791 $opt->{'o'}, $packed->n_pts, $packed->n_feats,
167             $packed->n_pts * $packed->n_feats * 8;
168              
169 1         230 return 1;
170             } ## end sub execute
171              
172             return 1;