File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/info.pm
Criterion Covered Total %
statement 49 53 92.4
branch 15 20 75.0
condition 4 6 66.6
subroutine 9 11 81.8
pod 4 5 80.0
total 81 95 85.2


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::info;
2              
3 9     9   5455 use strict;
  9         15  
  9         259  
4 9     9   29 use warnings;
  9         13  
  9         339  
5 9     9   58 use Algorithm::Classifier::IsolationForest ();
  9         14  
  9         201  
6 9     9   30 use Algorithm::Classifier::IsolationForest::App -command;
  9         12  
  9         63  
7              
8             sub opt_spec {
9             return (
10             [
11 2     2 1 49631 '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 2     2 0 4 my ( $self, $opt, $args ) = @_;
32              
33 2 50       97 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 2         9 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 984     984   1352 my ( $node, $depth, $acc ) = @_;
46 984         1113 $acc->{nodes}++;
47 984 100       1460 if ( $node->[0] == 0 ) { # leaf
48 522         599 $acc->{leaves}++;
49 522 100       739 $acc->{max_depth} = $depth if $depth > $acc->{max_depth};
50 522         608 $acc->{depth_sum} += $depth;
51 522         736 return;
52             }
53             # Axis interior nodes have children at slots 3,4; oblique at 4,5.
54 462 50       699 my ( $li, $ri ) = $node->[0] == 1 ? ( 3, 4 ) : ( 4, 5 );
55 462         861 _walk_tree( $node->[$li], $depth + 1, $acc );
56 462         674 _walk_tree( $node->[$ri], $depth + 1, $acc );
57             } ## end sub _walk_tree
58              
59             sub _tree_stats {
60 2     2   4 my ($trees) = @_;
61 2         11 my $acc = { nodes => 0, leaves => 0, max_depth => 0, depth_sum => 0 };
62 2         10 _walk_tree( $_, 0, $acc ) for @$trees;
63 2         5 return $acc;
64             }
65              
66             sub execute {
67 2     2 1 13 my ( $self, $opt, $args ) = @_;
68              
69 2         21 my $model = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
70              
71             # Tree stats are not stored on the model -- they're cheap to derive.
72 2         10 my $stats = _tree_stats( $model->{trees} );
73 2         4 my $n_trees = scalar @{ $model->{trees} };
  2         6  
74 2 50       9 my $avg_depth = $stats->{leaves} ? $stats->{depth_sum} / $stats->{leaves} : 0;
75 2 50       7 my $avg_nodes = $n_trees ? $stats->{nodes} / $n_trees : 0;
76              
77             my %info = (
78             'file' => $opt->{'m'},
79             'mode' => $model->{mode},
80             'n_trees' => $n_trees,
81             'n_features' => $model->{n_features},
82             'sample_size' => $model->{sample_size},
83             'psi_used' => $model->{psi_used},
84             'c_psi' => $model->{c_psi},
85             'max_depth_used' => $model->{max_depth_used},
86             'extension_level' => $model->{extension_level_used},
87             'contamination' => $model->{contamination},
88             'threshold' => $model->{threshold},
89             'tree_total_nodes' => $stats->{nodes},
90             'tree_total_leaves' => $stats->{leaves},
91             'tree_max_depth' => $stats->{max_depth},
92 2         28 'tree_avg_depth' => $avg_depth,
93             'tree_avg_nodes' => $avg_nodes,
94             );
95              
96 2 100       6 if ( $opt->{'json'} ) {
97 1         14 require JSON::PP;
98 1         17 print JSON::PP->new->canonical(1)->pretty->encode( \%info );
99 1         819 return 1;
100             }
101              
102             # Text-table output, in a stable order, with undef shown as "(unset)".
103 1         5 my @order = qw(
104             file mode n_trees n_features sample_size psi_used c_psi
105             max_depth_used extension_level contamination threshold
106             tree_total_nodes tree_total_leaves tree_max_depth
107             tree_avg_depth tree_avg_nodes
108             );
109              
110 1         2 for my $k (@order) {
111 16         25 my $v = $info{$k};
112 16 100       24 $v = '(unset)' unless defined $v;
113             # Pretty-print floats with a couple of decimals; leave ints raw.
114 16 100 66     70 $v = sprintf( '%.4f', $v )
      66        
115             if defined $v
116             && $v =~ /^-?\d+\.\d+/
117             && $k !~ /^tree_total_/;
118 16         62 printf " %-20s %s\n", $k, $v;
119             } ## end for my $k (@order)
120 1         216 return 1;
121             } ## end sub execute
122              
123             return 1;