File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/info.pm
Criterion Covered Total %
statement 110 114 96.4
branch 53 66 80.3
condition 19 27 70.3
subroutine 14 16 87.5
pod 4 5 80.0
total 200 228 87.7


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::info;
2              
3 81     81   52931 use strict;
  81         144  
  81         2643  
4 81     81   286 use warnings;
  81         137  
  81         2943  
5 81     81   330 use Algorithm::Classifier::IsolationForest ();
  81         147  
  81         1491  
6 81     81   261 use Algorithm::Classifier::IsolationForest::App -command;
  81         122  
  81         538  
7              
8             sub opt_spec {
9             return (
10             [
11 11     11 1 286200 'm=s',
12             'Input model JSON file path/name.',
13             { 'default' => 'iforest_model.json', 'completion' => 'files' }
14             ],
15             [ 'json', 'Emit machine-readable JSON instead of the text table.' ],
16             );
17             } ## end sub opt_spec
18              
19 0     0 1 0 sub abstract { 'Show the constructor params, fit-time metadata, and tree stats of a saved model' }
20              
21             sub description {
22 0     0 1 0 'Loads a saved Algorithm::Classifier::IsolationForest model and prints the
23             constructor params, fit-time metadata, and a handful of derived tree
24             statistics (count, average/max depth, total nodes).
25              
26             Use --json for a machine-readable dump suitable for piping into jq.
27             '
28             }
29              
30             sub validate {
31 11     11 0 31 my ( $self, $opt, $args ) = @_;
32              
33 11 50       420 if ( !-f $opt->{'m'} ) {
    50          
34 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not a file or does not exist' );
35             } elsif ( !-r $opt->{'m'} ) {
36 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not readable' );
37             }
38 11         40 return 1;
39             } ## end sub validate
40              
41             # Tree-shape stats are derived once at load time. Each tree is a
42             # nested arrayref structure -- leaf [0, size] or interior [1, ...] /
43             # [2, ...] with children at fixed slots.
44             sub _walk_tree {
45 6788     6788   8482 my ( $node, $depth, $acc ) = @_;
46 6788         7467 $acc->{nodes}++;
47 6788 100       9321 if ( $node->[0] == 0 ) { # leaf
48 3549         3963 $acc->{leaves}++;
49 3549 100       4824 $acc->{max_depth} = $depth if $depth > $acc->{max_depth};
50 3549         4006 $acc->{depth_sum} += $depth;
51 3549         4582 return;
52             }
53             # Axis interior nodes have children at slots 3,4; oblique at 4,5.
54 3239 50       4552 my ( $li, $ri ) = $node->[0] == 1 ? ( 3, 4 ) : ( 4, 5 );
55 3239         5490 _walk_tree( $node->[$li], $depth + 1, $acc );
56 3239         4181 _walk_tree( $node->[$ri], $depth + 1, $acc );
57             } ## end sub _walk_tree
58              
59             sub _tree_stats {
60 8     8   26 my ($trees) = @_;
61 8         49 my $acc = { nodes => 0, leaves => 0, max_depth => 0, depth_sum => 0 };
62 8         38 _walk_tree( $_, 0, $acc ) for @$trees;
63 8         32 return $acc;
64             }
65              
66             # Summary of a model's Algorithm::ToNumberMunger spec as a flat
67             # 'key => munger name' map (the full spec can be arbitrarily large --
68             # frozen count tables and the like -- so info only names the mungers).
69             # Returns undef when the model carries none.
70             sub _munger_summary {
71 11     11   38 my ($model) = @_;
72 11         35 my $mungers = $model->{mungers};
73 11 100 66     259 return undef unless ref $mungers eq 'HASH' && %$mungers;
74 1 50       5 return { map { $_ => ( ref $mungers->{$_} eq 'HASH' ? $mungers->{$_}{munger} : undef ) } keys %$mungers };
  2         31  
75             }
76              
77             # Text-table rendering of the summary, matching the feature_names style.
78             sub _print_mungers {
79 8     8   22 my ($summary) = @_;
80 8 100       26 return unless $summary;
81 1         6 printf " %-20s %s\n", 'mungers', scalar( keys %$summary ) . ' configured';
82 1         4 for my $k ( sort keys %$summary ) {
83 2 50       24 printf " %-18s %s\n", $k, ( defined $summary->{$k} ? $summary->{$k} : '(?)' );
84             }
85 1         3 return;
86             }
87              
88             # Online-model counterpart of _walk_tree: nodes are
89             # [0, count, lo, hi] / [1, count, lo, hi, attr, split, left, right],
90             # a tree record is { root, count, depth_limit }, and root may be undef
91             # on a tree that has not learned anything yet.
92             sub _walk_tree_online {
93 268     268   376 my ( $node, $depth, $acc ) = @_;
94 268         297 $acc->{nodes}++;
95 268 100       387 if ( $node->[0] == 0 ) { # leaf
96 164         183 $acc->{leaves}++;
97 164 100       243 $acc->{max_depth} = $depth if $depth > $acc->{max_depth};
98 164         191 $acc->{depth_sum} += $depth;
99 164         204 return;
100             }
101 104         193 _walk_tree_online( $node->[6], $depth + 1, $acc );
102 104         145 _walk_tree_online( $node->[7], $depth + 1, $acc );
103             } ## end sub _walk_tree_online
104              
105             sub _tree_stats_online {
106 3     3   8 my ($trees) = @_;
107 3         16 my $acc = { nodes => 0, leaves => 0, max_depth => 0, depth_sum => 0 };
108 3         10 for my $tree (@$trees) {
109 60 50       108 _walk_tree_online( $tree->{root}, 0, $acc ) if defined $tree->{root};
110             }
111 3         16 return $acc;
112             }
113              
114             # Online models have a different parameter set and tree shape; handled
115             # in a dedicated path so the batch-model reporting below stays simple.
116             sub _execute_online {
117 3     3   7 my ( $self, $opt, $model ) = @_;
118              
119 3         15 my $stats = _tree_stats_online( $model->{trees} );
120 3         5 my $n_trees = scalar @{ $model->{trees} };
  3         9  
121 3 50       24 my $avg_depth = $stats->{leaves} ? $stats->{depth_sum} / $stats->{leaves} : 0;
122 3 50       13 my $avg_nodes = $n_trees ? $stats->{nodes} / $n_trees : 0;
123              
124 3         6 my $tags = $model->{feature_names};
125 3 100 66     16 my $tagged = ( ref $tags eq 'ARRAY' && @$tags ) ? 1 : 0;
126              
127             my %info = (
128             'file' => $opt->{'m'},
129             'type' => 'online',
130             'tagged' => $tagged,
131             'feature_names' => $tagged ? $tags : undef,
132             'n_trees' => $n_trees,
133             'n_features' => $model->{n_features},
134             'window_size' => $model->{window_size},
135             'window_count' => $model->window_count,
136             'seen' => $model->{seen},
137             'max_leaf_samples' => $model->{max_leaf_samples},
138             'growth' => $model->{growth},
139             'subsample' => $model->{subsample},
140             'contamination' => $model->{contamination},
141             'threshold' => $model->{threshold},
142             'mungers' => _munger_summary($model),
143             'munger_module_version' => $model->{munger_module_version},
144             'schema_version' => $model->{schema_version},
145             'schema_description' => $model->{schema_description},
146             'feature_descriptions' => $model->{feature_descriptions},
147             'tree_total_nodes' => $stats->{nodes},
148             'tree_total_leaves' => $stats->{leaves},
149             'tree_max_depth' => $stats->{max_depth},
150 3 100       23 'tree_avg_depth' => $avg_depth,
151             'tree_avg_nodes' => $avg_nodes,
152             );
153              
154 3 100       24 if ( $opt->{'json'} ) {
155 1         14 require JSON::PP;
156 1         7 print JSON::PP->new->canonical(1)->pretty->encode( \%info );
157 1         898 return 1;
158             }
159              
160 2         12 my @order = qw(
161             file type tagged n_trees n_features window_size window_count seen
162             max_leaf_samples growth subsample contamination threshold
163             schema_version schema_description munger_module_version
164             tree_total_nodes tree_total_leaves tree_max_depth
165             tree_avg_depth tree_avg_nodes
166             );
167              
168 2         6 for my $k (@order) {
169 42         59 my $v = $info{$k};
170 42 100       73 $v = '(unset)' unless defined $v;
171 42 50 66     139 $v = sprintf( '%.4f', $v )
      66        
      66        
172             if defined $v
173             && $v =~ /^-?\d+\.\d+/
174             && $k !~ /^tree_total_/
175             && $k !~ /\A(?:munger_module_version|schema_version|schema_description)\z/;
176 42         114 printf " %-20s %s\n", $k, $v;
177             } ## end for my $k (@order)
178              
179 2 100       7 if ($tagged) {
180 1         4 printf " %-20s %s\n", 'feature_names', join( ', ', @$tags );
181 1 50       5 my $fd = ref $model->{feature_descriptions} eq 'HASH' ? $model->{feature_descriptions} : {};
182 1         4 for my $i ( 0 .. $#$tags ) {
183             printf " [%d] %s%s\n", $i, $tags->[$i],
184 3 50       11 ( defined $fd->{ $tags->[$i] } ? ' -- ' . $fd->{ $tags->[$i] } : '' );
185             }
186             }
187 2         8 _print_mungers( $info{mungers} );
188 2         390 return 1;
189             } ## end sub _execute_online
190              
191             sub execute {
192 11     11 1 74 my ( $self, $opt, $args ) = @_;
193              
194 11         103 my $model = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
195              
196             # load() dispatches on the stored format tag, so this may be an
197             # online model -- those have their own parameter set and tree shape.
198 11 100       55 if ( ref $model eq 'Algorithm::Classifier::IsolationForest::Online' ) {
199 3         22 return $self->_execute_online( $opt, $model );
200             }
201              
202             # Tree stats are not stored on the model -- they're cheap to derive.
203 8         44 my $stats = _tree_stats( $model->{trees} );
204 8         46 my $n_trees = scalar @{ $model->{trees} };
  8         25  
205 8 50       39 my $avg_depth = $stats->{leaves} ? $stats->{depth_sum} / $stats->{leaves} : 0;
206 8 50       40 my $avg_nodes = $n_trees ? $stats->{nodes} / $n_trees : 0;
207              
208             # Feature-name tags are stored as an arrayref (via -t at fit time).
209 8         17 my $tags = $model->{feature_names};
210 8 100 66     73 my $tagged = ( ref $tags eq 'ARRAY' && @$tags ) ? 1 : 0;
211              
212             my %info = (
213             'file' => $opt->{'m'},
214             'mode' => $model->{mode},
215             'voting' => $model->{voting},
216             'tagged' => $tagged,
217             'feature_names' => $tagged ? $tags : undef,
218             'n_trees' => $n_trees,
219             'n_features' => $model->{n_features},
220             'sample_size' => $model->{sample_size},
221             'psi_used' => $model->{psi_used},
222             'c_psi' => $model->{c_psi},
223             'max_depth_used' => $model->{max_depth_used},
224             'extension_level' => $model->{extension_level_used},
225             'contamination' => $model->{contamination},
226             'threshold' => $model->{threshold},
227             'mungers' => _munger_summary($model),
228             'munger_module_version' => $model->{munger_module_version},
229             'schema_version' => $model->{schema_version},
230             'schema_description' => $model->{schema_description},
231             'feature_descriptions' => $model->{feature_descriptions},
232             'tree_total_nodes' => $stats->{nodes},
233             'tree_total_leaves' => $stats->{leaves},
234             'tree_max_depth' => $stats->{max_depth},
235 8 100       87 'tree_avg_depth' => $avg_depth,
236             'tree_avg_nodes' => $avg_nodes,
237             );
238              
239 8 100       53 if ( $opt->{'json'} ) {
240 2         23 require JSON::PP;
241 2         15 print JSON::PP->new->canonical(1)->pretty->encode( \%info );
242 2         1874 return 1;
243             }
244              
245             # Text-table output, in a stable order, with undef shown as "(unset)".
246             # feature_names, feature_descriptions, and mungers are refs -- rendered
247             # separately below.
248 6         38 my @order = qw(
249             file mode voting tagged n_trees n_features sample_size psi_used c_psi
250             max_depth_used extension_level contamination threshold
251             schema_version schema_description munger_module_version
252             tree_total_nodes tree_total_leaves tree_max_depth
253             tree_avg_depth tree_avg_nodes
254             );
255              
256 6         14 for my $k (@order) {
257 126         185 my $v = $info{$k};
258 126 100       190 $v = '(unset)' unless defined $v;
259             # Pretty-print floats with a couple of decimals; leave ints raw.
260 126 100 66     530 $v = sprintf( '%.4f', $v )
      66        
      100        
261             if defined $v
262             && $v =~ /^-?\d+\.\d+/
263             && $k !~ /^tree_total_/
264             && $k !~ /\A(?:munger_module_version|schema_version|schema_description)\z/;
265 126         328 printf " %-20s %s\n", $k, $v;
266             } ## end for my $k (@order)
267              
268             # Feature-name tags, one per line, in stored (positional) order.
269 6 100       20 if ($tagged) {
270 3         17 printf " %-20s %s\n", 'feature_names', join( ', ', @$tags );
271 3 100       12 my $fd = ref $model->{feature_descriptions} eq 'HASH' ? $model->{feature_descriptions} : {};
272 3         12 for my $i ( 0 .. $#$tags ) {
273             printf " [%d] %s%s\n", $i, $tags->[$i],
274 9 100       41 ( defined $fd->{ $tags->[$i] } ? ' -- ' . $fd->{ $tags->[$i] } : '' );
275             }
276             }
277 6         34 _print_mungers( $info{mungers} );
278 6         1600 return 1;
279             } ## end sub execute
280              
281             return 1;