File Coverage

blib/lib/Algorithm/Classifier/NaiveBayes.pm
Criterion Covered Total %
statement 332 333 99.7
branch 187 194 96.3
condition 93 107 86.9
subroutine 21 21 100.0
pod 14 14 100.0
total 647 669 96.7


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::NaiveBayes;
2              
3 13     13   1063205 use 5.006;
  13         48  
4 13     13   64 use strict;
  13         26  
  13         346  
5 13     13   44 use warnings;
  13         32  
  13         591  
6 13     13   7983 use JSON::PP ();
  13         186416  
  13         391  
7 13     13   5779 use File::Slurp qw(read_file write_file);
  13         345574  
  13         48740  
8              
9             =head1 NAME
10              
11             Algorithm::Classifier::NaiveBayes - A multinomial naive Bayes text classifier with Laplace smoothing.
12              
13             =head1 VERSION
14              
15             Version 0.0.1
16              
17             =cut
18              
19             our $VERSION = '0.0.1';
20              
21             # version of the saved model format
22             our $MODEL_VERSION = 1;
23              
24             =head1 SYNOPSIS
25              
26             use Algorithm::Classifier::NaiveBayes;
27              
28             my $nb = Algorithm::Classifier::NaiveBayes->new;
29              
30             # train it with examples of each class
31             $nb->train( 'spam', 'buy cheap pills now' );
32             $nb->train( 'spam', 'cheap watches for sale' );
33             $nb->train( 'ham', 'meeting at noon tomorrow' );
34             $nb->train( 'ham', 'lunch with the team' );
35              
36             # classify some new text
37             my $class = $nb->classify('cheap pills for sale');
38             # $class is now 'spam'
39              
40             # or get the score and probability for every class as well
41             my ( $best, $scores, $probs ) = $nb->classify('cheap pills for sale');
42              
43             # save the model for later and load it again
44             $nb->save('model.json');
45              
46             my $loaded = Algorithm::Classifier::NaiveBayes->new;
47             $loaded->load('model.json');
48              
49             =head1 DESCRIPTION
50              
51             This module implements a multinomial naive Bayes classifier. Strings
52             are broken into tokens and each class is scored using the log of its
53             prior probability, based on how often the class was trained, plus the
54             sum of the log probabilities of each token appearing in that class.
55             Token probabilities are smoothed so tokens never seen for a class do
56             not zero out the whole score. By default this is add-one, Laplace,
57             smoothing, but Lidstone, add-alpha, smoothing with a configurable
58             alpha may be selected instead. Smaller alphas, such as 0.1 to 0.5,
59             often perform better on small training sets.
60              
61             By default token occurrences are weighted by their raw counts, but
62             binary weighting, counting each unique token once per document, may
63             be selected instead via token_weighting. Class priors default to how
64             often each class was trained, but may be set to uniform via priors.
65              
66             Classes are not predefined. A class exists once something has been
67             trained for it and stops existing if everything for it is untrained.
68              
69             The model may be saved to a JSON file or string and loaded back later,
70             allowing training and classification to happen in different processes.
71              
72             =head1 METHODS
73              
74             =head2 new
75              
76             Initiates the object.
77              
78             my $nb = Algorithm::Classifier::NaiveBayes->new(%args);
79              
80             The following args are supported.
81              
82             lc_tokens - Lowercase tokens when tokenizing.
83             Default: 1
84              
85             token_splitter - Regex to use for splitting a string into tokens.
86             Default: \s+
87              
88             stop_regex - If defined, tokens matching this regex are dropped.
89             Matched anchored, so it must match the entire token.
90             Default: undef
91              
92             smoothing - The smoothing to use for token probabilities. Either
93             "laplace", add-one, or "lidstone", add-alpha.
94             Default: laplace
95              
96             alpha - The alpha to use for lidstone smoothing. Must be a number
97             greater than 0. May only be specified when smoothing is set to
98             lidstone. Laplace smoothing is lidstone with a alpha of 1.
99             Default: 0.5
100              
101             ngrams - Max size of n-grams to generate from adjacent tokens when
102             tokenizing. 1 means single tokens only. 2 means also generate
103             each adjacent pair of tokens joined by a space. 3 also adds
104             triplets and so on.
105             Default: 1
106              
107             token_weighting - How token occurrences are weighted. "count" uses
108             raw counts, so a token appearing three times in a document
109             counts three times. "binary" counts each unique token once per
110             document, both when training and classifying, which often works
111             better for short texts. Also known as binarized multinomial
112             naive Bayes.
113             Default: count
114              
115             priors - How class priors are computed when classifying. "trained"
116             uses how often each class was trained, so classes with more
117             documents are favored. "uniform" gives every class a equal
118             prior, useful when the training set is unbalanced in a way real
119             usage will not be.
120             Default: trained
121              
122             token_splitter and stop_regex may be either a string or a qr// Regexp.
123              
124             Will die if passed a unknown arg or if token_splitter or stop_regex
125             is a empty string, a ref other than a qr// Regexp, or does not compile
126             as a regex.
127              
128             Some examples...
129              
130             # split on commas instead of whitespace
131             my $nb = Algorithm::Classifier::NaiveBayes->new( 'token_splitter' => ',' );
132              
133             # keep the case of tokens
134             my $nb = Algorithm::Classifier::NaiveBayes->new( 'lc_tokens' => 0 );
135              
136             # drop some common stop words
137             my $nb = Algorithm::Classifier::NaiveBayes->new( 'stop_regex' => qr/a|an|and|the|of|to/ );
138              
139             # use lidstone smoothing with a alpha of 0.1
140             my $nb = Algorithm::Classifier::NaiveBayes->new( 'smoothing' => 'lidstone', 'alpha' => 0.1 );
141              
142             # also generate bigrams, so phrases like "free cruise" become tokens
143             my $nb = Algorithm::Classifier::NaiveBayes->new( 'ngrams' => 2 );
144              
145             # count each unique token once per document
146             my $nb = Algorithm::Classifier::NaiveBayes->new( 'token_weighting' => 'binary' );
147              
148             # give every class a equal prior regardless of training balance
149             my $nb = Algorithm::Classifier::NaiveBayes->new( 'priors' => 'uniform' );
150              
151             =cut
152              
153             sub new {
154 79     79 1 1366750 my ( $pkg, %args ) = @_;
155              
156 79         398 my %known_args = (
157             'lc_tokens' => 1,
158             'token_splitter' => 1,
159             'stop_regex' => 1,
160             'smoothing' => 1,
161             'alpha' => 1,
162             'ngrams' => 1,
163             'token_weighting' => 1,
164             'priors' => 1,
165             );
166 79         186 foreach my $arg ( keys %args ) {
167 47 100       123 if ( !defined( $known_args{$arg} ) ) {
168 1         9 die( '"' . $arg . '" is not a known arg' );
169             }
170             }
171              
172 78 100 100     256 if ( defined( $args{'lc_tokens'} ) && ref( $args{'lc_tokens'} ) ne '' ) {
173 1         8 die( 'lc_tokens must be a boolean and not a ref of type "' . ref( $args{'lc_tokens'} ) . '"' );
174             }
175              
176 77         171 foreach my $regex_arg ( 'token_splitter', 'stop_regex' ) {
177 152 100       300 if ( defined( $args{$regex_arg} ) ) {
178 11         22 my $ref = ref( $args{$regex_arg} );
179 11 100 100     38 if ( $ref ne '' && $ref ne 'Regexp' ) {
180 1         8 die( $regex_arg . ' must be a string or qr// Regexp and not a ref of type "' . $ref . '"' );
181             }
182 10 100       22 if ( $args{$regex_arg} eq '' ) {
183 1         8 die( $regex_arg . ' may not be a empty string' );
184             }
185 9         12 my $compiled = eval { qr/$args{$regex_arg}/ };
  9         156  
186 9 100       36 if ( !defined($compiled) ) {
187 2         13 die( $regex_arg . ', "' . $args{$regex_arg} . '", does not compile as a regex... ' . $@ );
188             }
189             } ## end if ( defined( $args{$regex_arg} ) )
190             } ## end foreach my $regex_arg ( 'token_splitter', 'stop_regex')
191              
192 73 100       184 my $smoothing = defined( $args{'smoothing'} ) ? $args{'smoothing'} : 'laplace';
193 73 100 100     219 if ( $smoothing ne 'laplace' && $smoothing ne 'lidstone' ) {
194 1         8 die( 'smoothing must be either "laplace" or "lidstone" and not "' . $smoothing . '"' );
195             }
196 72         111 my $alpha;
197 72 100       134 if ( defined( $args{'alpha'} ) ) {
198 7 100       16 if ( $smoothing eq 'laplace' ) {
199 1         8 die('alpha may only be specified when smoothing is set to lidstone');
200             }
201 6 100 66     75 if ( ref( $args{'alpha'} ) ne '' || $args{'alpha'} !~ /\A\d*\.?\d+\z/ || $args{'alpha'} <= 0 ) {
      100        
202 2         15 die('alpha must be a number greater than 0');
203             }
204 4         9 $alpha = $args{'alpha'};
205             } else {
206 65 100       129 $alpha = $smoothing eq 'lidstone' ? 0.5 : 1;
207             }
208              
209 69 100       129 my $ngrams = defined( $args{'ngrams'} ) ? $args{'ngrams'} : 1;
210 69 100 66     584 if ( ref($ngrams) ne '' || $ngrams !~ /\A\d+\z/ || $ngrams < 1 ) {
      100        
211 3         22 die('ngrams must be a whole number greater than 0');
212             }
213              
214 66 100       129 my $token_weighting = defined( $args{'token_weighting'} ) ? $args{'token_weighting'} : 'count';
215 66 100 100     154 if ( $token_weighting ne 'count' && $token_weighting ne 'binary' ) {
216 1         9 die( 'token_weighting must be either "count" or "binary" and not "' . $token_weighting . '"' );
217             }
218              
219 65 100       145 my $priors = defined( $args{'priors'} ) ? $args{'priors'} : 'trained';
220 65 100 100     152 if ( $priors ne 'trained' && $priors ne 'uniform' ) {
221 1         13 die( 'priors must be either "trained" or "uniform" and not "' . $priors . '"' );
222             }
223              
224             my $self = {
225             'model' => {
226             'format' => __PACKAGE__,
227             'version' => $MODEL_VERSION,
228             'smoothing' => $smoothing,
229             'alpha' => $alpha,
230             'ngrams' => $ngrams,
231             'token_weighting' => $token_weighting,
232             'priors' => $priors,
233             'class_counts' => {},
234             'token_counts' => {},
235             'class_totals' => {},
236             'tokens' => {},
237             'total_docs' => 0,
238             'lc_tokens' => defined( $args{'lc_tokens'} ) ? $args{'lc_tokens'} : 1,
239             'token_splitter' => defined( $args{'token_splitter'} ) ? $args{'token_splitter'} : '\s+',
240 64 100       535 'stop_regex' => $args{'stop_regex'},
    100          
241             },
242             };
243 64         129 bless $self, $pkg;
244              
245 64         223 return $self;
246             } ## end sub new
247              
248             =head2 tokenize
249              
250             Tokenizes the specified string. This is used internally by train,
251             untrain, and classify, but may also be called directly to see how a
252             string will be broken up.
253              
254             my @tokens = $nb->tokenize($string);
255              
256             The string is split via the token_splitter regex. Empty tokens are
257             dropped. If lc_tokens is true, tokens are lowercased. If stop_regex is
258             defined, tokens entirely matching it are dropped.
259              
260             If ngrams is greater than 1, n-grams up to that size are generated
261             from adjacent tokens and appended, joined by a space. This happens
262             after lowercasing and stop word removal, so stop words do not appear
263             inside n-grams.
264              
265             my $nb = Algorithm::Classifier::NaiveBayes->new( 'ngrams' => 2 );
266             my @tokens = $nb->tokenize('Free Cruise Inside');
267             # ( 'free', 'cruise', 'inside', 'free cruise', 'cruise inside' )
268              
269             Will die if the string is undef. As train, untrain, and classify all
270             use this, passing undef text to any of those will also die.
271              
272             my $nb = Algorithm::Classifier::NaiveBayes->new;
273             my @tokens = $nb->tokenize('Buy Cheap Pills');
274             # ( 'buy', 'cheap', 'pills' )
275              
276             =cut
277              
278             sub tokenize {
279 98     98 1 2719 my ( $self, $text ) = @_;
280              
281 98 100       218 if ( !defined($text) ) {
282 2         13 die('No text specified');
283             }
284              
285 96         156 my $split_regex = $self->{'model'}{'token_splitter'};
286 96         592 my @tokens = split( /$split_regex/, $text );
287 96         127 my @final_tokens;
288 96         169 foreach my $token (@tokens) {
289 271 100       487 if ( $token eq '' ) {
290 2         4 next;
291             }
292 269 100       429 if ( $self->{'model'}{'lc_tokens'} ) {
293 266         350 $token = lc($token);
294             }
295 269         300 my $add_token = 1;
296 269 100       423 if ( defined( $self->{'model'}{'stop_regex'} ) ) {
297 15         21 my $stop_regex = $self->{'model'}{'stop_regex'};
298 15 100       129 if ( $token =~ /\A(?:$stop_regex)\z/ ) {
299 7         11 $add_token = 0;
300             }
301             }
302 269 100       387 if ($add_token) {
303 262         415 push( @final_tokens, $token );
304             }
305             } ## end foreach my $token (@tokens)
306              
307             # generate n-grams from adjacent tokens if enabled
308 96 100 66     325 if ( defined( $self->{'model'}{'ngrams'} ) && $self->{'model'}{'ngrams'} > 1 ) {
309 4         6 my @grams;
310 4         8 for my $n ( 2 .. $self->{'model'}{'ngrams'} ) {
311 5         13 for my $i ( 0 .. $#final_tokens - $n + 1 ) {
312 6         15 push( @grams, join( ' ', @final_tokens[ $i .. ( $i + $n - 1 ) ] ) );
313             }
314             }
315 4         8 push( @final_tokens, @grams );
316             }
317              
318 96         361 return @final_tokens;
319             } ## end sub tokenize
320              
321             =head2 train
322              
323             Train a specific class on the specified string.
324              
325             $nb->train($class, $string);
326              
327             Will die if the class or string is undef.
328              
329             The class does not need to exist prior to this being called. Training
330             a new class name brings that class into existence.
331              
332             $nb->train( 'spam', 'buy cheap pills now' );
333             $nb->train( 'ham', 'meeting at noon tomorrow' );
334              
335             =cut
336              
337             sub train {
338 52     52 1 3185 my ( $self, $class, $text ) = @_;
339              
340 52 100       140 if ( !defined($class) ) {
    100          
341 1         9 die('No class specified');
342             } elsif ( !defined($text) ) {
343 1         7 die('No text specified');
344             }
345              
346 50         124 $self->{'model'}{'class_counts'}{$class}++;
347 50         72 $self->{'model'}{'total_docs'}++;
348 50 100       99 if ( !defined( $self->{'model'}{'token_counts'}{$class} ) ) {
349 37         96 $self->{'model'}{'token_counts'}{$class} = {};
350             }
351 50 100       96 if ( !defined( $self->{'model'}{'class_totals'}{$class} ) ) {
352 37         65 $self->{'model'}{'class_totals'}{$class} = 0;
353             }
354 50         100 for my $word ( $self->_weighted_tokens( $self->tokenize($text) ) ) {
355 150         320 $self->{'model'}{'token_counts'}{$class}{$word}++;
356 150         199 $self->{'model'}{'class_totals'}{$class}++;
357 150         300 $self->{'model'}{'tokens'}{$word} = 1;
358             }
359             } ## end sub train
360              
361             # returns the log prior probability for a class per the priors setting
362             sub _log_prior {
363 49     49   80 my ( $self, $class ) = @_;
364              
365 49 100 66     158 if ( defined( $self->{'model'}{'priors'} ) && $self->{'model'}{'priors'} eq 'uniform' ) {
366 6         10 my $num_classes = scalar keys %{ $self->{'model'}{'class_counts'} };
  6         9  
367 6         17 return log( 1 / $num_classes );
368             }
369              
370 43         137 return log( $self->{'model'}{'class_counts'}{$class} / $self->{'model'}{'total_docs'} );
371             } ## end sub _log_prior
372              
373             # applies the token_weighting setting to a list of tokens... for binary
374             # weighting each unique token is only counted once
375             sub _weighted_tokens {
376 85     85   181 my ( $self, @tokens ) = @_;
377              
378 85 100 66     271 if ( defined( $self->{'model'}{'token_weighting'} ) && $self->{'model'}{'token_weighting'} eq 'binary' ) {
379 7         9 my %seen;
380 7         12 @tokens = grep { !$seen{$_}++ } @tokens;
  20         46  
381             }
382              
383 85         176 return @tokens;
384             } ## end sub _weighted_tokens
385              
386             =head2 untrain
387              
388             Untrain a specific class on the specified string, reversing a previous
389             call to train with the same class and string.
390              
391             $nb->untrain($class, $string);
392              
393             Will die if the class or string is undef.
394              
395             If the class in question has not been trained, this is a noop. Token
396             counts will not be decremented below zero and classes with no remaining
397             trained documents are removed from the model.
398              
399             # trained into the wrong class, so move it
400             $nb->untrain( 'ham', 'buy cheap pills now' );
401             $nb->train( 'spam', 'buy cheap pills now' );
402              
403             It is worth noting it can't be verified the string in question was
404             actually previously trained for that class. Untraining a string that
405             differs from what was trained will still decrement the document count
406             for the class, along with whatever tokens overlap.
407              
408             =cut
409              
410             sub untrain {
411 12     12 1 4708 my ( $self, $class, $text ) = @_;
412              
413 12 100       38 if ( !defined($class) ) {
    100          
414 1         7 die('No class specified');
415             } elsif ( !defined($text) ) {
416 1         7 die('No text specified');
417             }
418              
419 10 100 66     51 if ( !defined( $self->{'model'}{'class_counts'}{$class} )
420             || $self->{'model'}{'class_counts'}{$class} < 1 )
421             {
422 2         4 return;
423             }
424              
425 8         41 $self->{'model'}{'class_counts'}{$class}--;
426 8         28 $self->{'model'}{'total_docs'}--;
427              
428 8         19 for my $word ( $self->_weighted_tokens( $self->tokenize($text) ) ) {
429 29 100       54 if ( defined( $self->{'model'}{'token_counts'}{$class}{$word} ) ) {
430 25         34 $self->{'model'}{'token_counts'}{$class}{$word}--;
431 25         28 $self->{'model'}{'class_totals'}{$class}--;
432 25 100       42 if ( $self->{'model'}{'token_counts'}{$class}{$word} < 1 ) {
433 20         33 delete( $self->{'model'}{'token_counts'}{$class}{$word} );
434             }
435             }
436             }
437              
438 8 100       22 if ( $self->{'model'}{'class_counts'}{$class} < 1 ) {
439 5         10 delete( $self->{'model'}{'class_counts'}{$class} );
440 5         37 delete( $self->{'model'}{'token_counts'}{$class} );
441 5         12 delete( $self->{'model'}{'class_totals'}{$class} );
442             }
443              
444             # rebuild the vocabulary as some tokens may no longer be in any class
445 8         26 $self->{'model'}{'tokens'} = {};
446 8         11 foreach my $rebuild_class ( keys %{ $self->{'model'}{'token_counts'} } ) {
  8         28  
447 9         25 foreach my $word ( keys %{ $self->{'model'}{'token_counts'}{$rebuild_class} } ) {
  9         22  
448 29         68 $self->{'model'}{'tokens'}{$word} = 1;
449             }
450             }
451             } ## end sub untrain
452              
453             =head2 prune
454              
455             Removes all tokens trained fewer than the specified number of times,
456             totaled across all classes.
457              
458             my $pruned = $nb->prune($min_count);
459              
460             Real world training data tends to accumulate a long tail of tokens
461             only seen once or twice. Those add noise and bloat the saved model,
462             so pruning them can be useful after a large amount of training.
463              
464             # remove all tokens only trained once
465             my $pruned = $nb->prune(2);
466              
467             Returns the number of tokens removed. Removed tokens are dropped from
468             the vocabulary and the per class token totals are decremented, but
469             document counts are untouched, so class priors are unchanged.
470              
471             Will die if min count is undef or not a whole number greater than 0.
472             A min count of 1 is a noop as every trained token has a count of at
473             least 1.
474              
475             =cut
476              
477             sub prune {
478 7     7 1 1702 my ( $self, $min_count ) = @_;
479              
480 7 100       19 if ( !defined($min_count) ) {
481 1         9 die('No min count specified');
482             }
483 6 100 66     49 if ( ref($min_count) ne '' || $min_count !~ /\A\d+\z/ || $min_count < 1 ) {
      100        
484 2         14 die('min count must be a whole number greater than 0');
485             }
486              
487             # total up each token across all classes
488 4         6 my %totals;
489 4         5 foreach my $class ( keys %{ $self->{'model'}{'token_counts'} } ) {
  4         14  
490 8         11 foreach my $token ( keys %{ $self->{'model'}{'token_counts'}{$class} } ) {
  8         19  
491 24         44 $totals{$token} += $self->{'model'}{'token_counts'}{$class}{$token};
492             }
493             }
494              
495 4         6 my $pruned = 0;
496 4         8 foreach my $token ( keys %totals ) {
497 21 100       47 if ( $totals{$token} < $min_count ) {
498 13         15 $pruned++;
499 13         18 foreach my $class ( keys %{ $self->{'model'}{'token_counts'} } ) {
  13         18  
500 26 100       47 if ( defined( $self->{'model'}{'token_counts'}{$class}{$token} ) ) {
501 14         21 $self->{'model'}{'class_totals'}{$class} -= $self->{'model'}{'token_counts'}{$class}{$token};
502 14         21 delete( $self->{'model'}{'token_counts'}{$class}{$token} );
503             }
504             }
505 13         17 delete( $self->{'model'}{'tokens'}{$token} );
506             } ## end if ( $totals{$token} < $min_count )
507             } ## end foreach my $token ( keys %totals )
508              
509 4         17 return $pruned;
510             } ## end sub prune
511              
512             =head2 classes
513              
514             Returns a sorted list of all currently trained classes.
515              
516             my @classes = $nb->classes;
517              
518             If nothing has been trained yet, an empty list is returned.
519              
520             =cut
521              
522             sub classes {
523 6     6 1 17 my ($self) = @_;
524              
525 6         8 return sort( keys( %{ $self->{'model'}{'class_counts'} } ) );
  6         37  
526             }
527              
528             =head2 class_tokens
529              
530             Returns a sorted list of all tokens trained for the specified class.
531              
532             my @tokens = $nb->class_tokens($class);
533              
534             Will die if no class is specified or if the class in question does not
535             exist.
536              
537             foreach my $class ( $nb->classes ) {
538             print $class . ': ' . join( ', ', $nb->class_tokens($class) ) . "\n";
539             }
540              
541             =cut
542              
543             sub class_tokens {
544 7     7 1 533 my ( $self, $class ) = @_;
545              
546 7 100       40 if ( !defined($class) ) {
    100          
547 1         7 die('No class specified');
548             } elsif ( !defined( $self->{'model'}{'token_counts'}{$class} ) ) {
549 2         13 die( 'The class "' . $class . '" does not exist' );
550             }
551              
552 4         5 return sort( keys( %{ $self->{'model'}{'token_counts'}{$class} } ) );
  4         31  
553             } ## end sub class_tokens
554              
555             =head2 classify
556              
557             Classify the text in question.
558              
559             my $class = $nb->classify($text);
560              
561             In scalar context, returns the name of the class the text most likely
562             belongs to. In list context, also returns a hash ref of the score for
563             every class as well as a hash ref of the probability of every class.
564              
565             my ( $class, $scores, $probs ) = $nb->classify($text);
566             foreach my $possible ( sort { $scores->{$b} <=> $scores->{$a} } keys %{$scores} ) {
567             print $possible . ': ' . $scores->{$possible} . ', ' . $probs->{$possible} . "\n";
568             }
569              
570             The scores are log probabilities, so they are negative numbers with
571             the one closest to zero being the most likely.
572              
573             The probabilities are the scores normalized to sum to 1, so they may
574             be used for things like requiring a minimum confidence.
575              
576             my ( $class, $scores, $probs ) = $nb->classify($text);
577             if ( $probs->{$class} < 0.8 ) {
578             $class = 'unsure';
579             }
580              
581             It is worth noting naive Bayes probabilities tend to be overconfident
582             thanks to the assumption tokens are independent of each other, with
583             longer texts commonly producing probabilities very close to 1 or 0.
584             They are good for ranking and thresholding, but should not be taken
585             as calibrated probabilities.
586              
587             If nothing has been trained yet, undef is returned in scalar context
588             and ( undef, {}, {} ) in list context.
589              
590             Ties are broken by sorting the tied class names, making the result
591             deterministic.
592              
593             =cut
594              
595             sub classify {
596 26     26 1 12219 my ( $self, $text ) = @_;
597              
598 26 100       75 if ( $self->{'model'}{'total_docs'} < 1 ) {
599 3 100       14 return wantarray ? ( undef, {}, {} ) : undef;
600             }
601              
602 23         72 my @tokens = $self->_weighted_tokens( $self->tokenize($text) );
603 23         36 my $token_size = scalar keys %{ $self->{'model'}{'tokens'} };
  23         54  
604 23 50       65 my $alpha = defined( $self->{'model'}{'alpha'} ) ? $self->{'model'}{'alpha'} : 1;
605              
606 23         33 my %scores;
607 23         31 for my $class ( keys %{ $self->{'model'}{'class_counts'} } ) {
  23         68  
608 41         107 my $log_prob = $self->_log_prior($class);
609 41   100     90 my $total = $self->{'model'}{'class_totals'}{$class} || 0;
610              
611 41 100       87 if ( ( $total + ( $alpha * $token_size ) ) > 0 ) {
612 39         56 for my $token (@tokens) {
613 72   100     190 my $count = $self->{'model'}{'token_counts'}{$class}{$token} || 0;
614 72         135 $log_prob += log( ( $count + $alpha ) / ( $total + ( $alpha * $token_size ) ) );
615             }
616             }
617 41         74 $scores{$class} = $log_prob;
618             } ## end for my $class ( keys %{ $self->{'model'}{'class_counts'...}})
619              
620 23 50       103 my ($best) = sort { $scores{$b} <=> $scores{$a} || $a cmp $b } keys %scores;
  18         80  
621              
622 23 100       74 if ( !wantarray ) {
623 10         59 return $best;
624             }
625              
626             # normalize the log scores into probabilities, shifting by the max
627             # so exp does not underflow for large negative log scores
628 13         19 my $max = $scores{$best};
629 13         18 my %probs;
630 13         22 my $prob_sum = 0;
631 13         43 for my $class ( keys %scores ) {
632 22         69 $probs{$class} = exp( $scores{$class} - $max );
633 22         34 $prob_sum += $probs{$class};
634             }
635 13         26 for my $class ( keys %probs ) {
636 22         34 $probs{$class} = $probs{$class} / $prob_sum;
637             }
638              
639 13         48 return ( $best, \%scores, \%probs );
640             } ## end sub classify
641              
642             =head2 explain
643              
644             Classifies the text in question like classify, but returns a hash ref
645             breaking down how the result was arrived at.
646              
647             my $explanation = $nb->explain($text);
648              
649             The returned hash ref is as below.
650              
651             class - The best matching class, as classify would return.
652              
653             scores - Hash ref of the log score of every class, as classify
654             would return.
655              
656             probs - Hash ref of the probability of every class, as classify
657             would return.
658              
659             priors - Hash ref of the log prior probability of every class,
660             the part of the score that comes from how often the class was
661             trained rather than from the tokens.
662              
663             tokens - Hash ref of every token in the tokenized text. Each value
664             is a hash ref with "count", how many times the token appeared
665             in the text, and "contributions", a hash ref of the log
666             probability that token added to each class per appearance.
667              
668             For any class, the score is the prior plus count * contribution summed
669             over every token. A token pushes towards the class it has the highest,
670             closest to zero, contribution for. So finding the tokens most
671             responsible for a classification can be done like below.
672              
673             my $explanation = $nb->explain($text);
674             my ( $first, $second ) =
675             sort { $explanation->{'scores'}{$b} <=> $explanation->{'scores'}{$a} }
676             keys %{ $explanation->{'scores'} };
677             foreach my $token ( keys %{ $explanation->{'tokens'} } ) {
678             my $contribs = $explanation->{'tokens'}{$token}{'contributions'};
679             my $pull = ( $contribs->{$first} - $contribs->{$second} )
680             * $explanation->{'tokens'}{$token}{'count'};
681             print $token . ' pushed towards ' . $first . ' by ' . $pull . "\n";
682             }
683              
684             Will die if the text is undef. If nothing has been trained yet, undef
685             is returned.
686              
687             =cut
688              
689             sub explain {
690 6     6 1 3468 my ( $self, $text ) = @_;
691              
692 6 100       12 if ( !defined($text) ) {
693 1         8 die('No text specified');
694             }
695              
696 5 100       14 if ( $self->{'model'}{'total_docs'} < 1 ) {
697 1         6 return undef;
698             }
699              
700 4         10 my @tokens = $self->_weighted_tokens( $self->tokenize($text) );
701 4         6 my $token_size = scalar keys %{ $self->{'model'}{'tokens'} };
  4         10  
702 4 50       10 my $alpha = defined( $self->{'model'}{'alpha'} ) ? $self->{'model'}{'alpha'} : 1;
703              
704 4         9 my %text_counts;
705 4         7 foreach my $token (@tokens) {
706 10         16 $text_counts{$token}++;
707             }
708              
709 4         9 my %priors;
710             my %scores;
711 4         0 my %token_info;
712 4         6 for my $class ( keys %{ $self->{'model'}{'class_counts'} } ) {
  4         11  
713 8         22 $priors{$class} = $self->_log_prior($class);
714 8         24 my $log_prob = $priors{$class};
715 8   50     15 my $total = $self->{'model'}{'class_totals'}{$class} || 0;
716 8         14 my $denom = $total + ( $alpha * $token_size );
717              
718 8 50       13 if ( $denom > 0 ) {
719 8         15 foreach my $token ( keys %text_counts ) {
720 18   100     37 my $count = $self->{'model'}{'token_counts'}{$class}{$token} || 0;
721 18         24 my $contribution = log( ( $count + $alpha ) / $denom );
722 18         28 $token_info{$token}{'count'} = $text_counts{$token};
723 18         49 $token_info{$token}{'contributions'}{$class} = $contribution;
724 18         28 $log_prob += $contribution * $text_counts{$token};
725             }
726             }
727 8         15 $scores{$class} = $log_prob;
728             } ## end for my $class ( keys %{ $self->{'model'}{'class_counts'...}})
729              
730 4 50       18 my ($best) = sort { $scores{$b} <=> $scores{$a} || $a cmp $b } keys %scores;
  4         15  
731              
732 4         6 my $max = $scores{$best};
733 4         6 my %probs;
734 4         5 my $prob_sum = 0;
735 4         7 for my $class ( keys %scores ) {
736 8         25 $probs{$class} = exp( $scores{$class} - $max );
737 8         17 $prob_sum += $probs{$class};
738             }
739 4         8 for my $class ( keys %probs ) {
740 8         13 $probs{$class} = $probs{$class} / $prob_sum;
741             }
742              
743             return {
744 4         34 'class' => $best,
745             'scores' => \%scores,
746             'probs' => \%probs,
747             'priors' => \%priors,
748             'tokens' => \%token_info,
749             };
750             } ## end sub explain
751              
752             =head2 tweak
753              
754             Changes scoring settings on a existing model. Takes the args below,
755             all optional, but at least one must be specified.
756              
757             smoothing - The smoothing to use... laplace or lidstone.
758              
759             alpha - The alpha to use for lidstone smoothing. Must be a number
760             greater than 0. May only be specified when the resulting
761             smoothing is lidstone.
762              
763             priors - How class priors are computed... trained or uniform.
764              
765             # switch to lidstone smoothing with a alpha of 0.1
766             $nb->tweak( 'smoothing' => 'lidstone', 'alpha' => 0.1 );
767              
768             # switch to uniform priors
769             $nb->tweak( 'priors' => 'uniform' );
770              
771             These are safe to change after training as they only affect scoring,
772             not the trained counts. Settings that shape the trained data, such as
773             ngrams, token_weighting, and the tokenizer settings, may not be
774             changed here as that would make the model inconsistent with what was
775             trained... for those, create a new object and retrain.
776              
777             Only args specified with a defined value are changed. Args passed
778             with a undef value are ignored, so it is safe to pass through
779             possibly unset values.
780              
781             Switching smoothing to laplace sets alpha to 1, as laplace is add-one.
782             Switching to lidstone without specifying alpha keeps the current
783             alpha.
784              
785             Will die if passed a unknown arg, no args with defined values, or a
786             insane value. If it dies, the model is left unchanged.
787              
788             =cut
789              
790             sub tweak {
791 20     20 1 9985 my ( $self, %args ) = @_;
792              
793 20         50 my %known_args = ( 'smoothing' => 1, 'alpha' => 1, 'priors' => 1 );
794 20         43 foreach my $arg ( keys %args ) {
795 32 100       65 if ( !defined( $known_args{$arg} ) ) {
796 2         14 die( '"' . $arg . '" is not a known arg' );
797             }
798             }
799 18 100       49 if ( !grep { defined( $args{$_} ) } keys %args ) {
  30         64  
800 2         15 die('No args specified');
801             }
802              
803             # validate against what the settings would become
804 16 100       36 my $smoothing = defined( $args{'smoothing'} ) ? $args{'smoothing'} : $self->{'model'}{'smoothing'};
805 16 50       25 if ( !defined($smoothing) ) {
806 0         0 $smoothing = 'laplace';
807             }
808 16 100 100     55 if ( $smoothing ne 'laplace' && $smoothing ne 'lidstone' ) {
809 2         30 die( 'smoothing must be either "laplace" or "lidstone" and not "' . $smoothing . '"' );
810             }
811              
812 14 100       26 if ( defined( $args{'alpha'} ) ) {
813 7 100       15 if ( $smoothing eq 'laplace' ) {
814 1         8 die('alpha may only be specified when the resulting smoothing is lidstone');
815             }
816 6 100 66     68 if ( ref( $args{'alpha'} ) ne '' || $args{'alpha'} !~ /\A\d*\.?\d+\z/ || $args{'alpha'} <= 0 ) {
      100        
817 2         15 die('alpha must be a number greater than 0');
818             }
819             }
820              
821 11 100 100     39 if ( defined( $args{'priors'} ) && $args{'priors'} ne 'trained' && $args{'priors'} ne 'uniform' ) {
      100        
822 1         8 die( 'priors must be either "trained" or "uniform" and not "' . $args{'priors'} . '"' );
823             }
824              
825             # only change what was specified with a defined value
826 10 100       45 if ( defined( $args{'smoothing'} ) ) {
827 6         12 $self->{'model'}{'smoothing'} = $args{'smoothing'};
828 6 100       13 if ( $args{'smoothing'} eq 'laplace' ) {
829             # laplace is add-one, so alpha is always 1
830 1         2 $self->{'model'}{'alpha'} = 1;
831             }
832             }
833 10 100       15 if ( defined( $args{'alpha'} ) ) {
834 4         9 $self->{'model'}{'alpha'} = $args{'alpha'};
835             }
836 10 100       27 if ( defined( $args{'priors'} ) ) {
837 4         12 $self->{'model'}{'priors'} = $args{'priors'};
838             }
839             } ## end sub tweak
840              
841             =head2 to_string
842              
843             Returns the model as a JSON string. See the section MODEL FORMAT for
844             what the JSON looks like.
845              
846             my $json = $nb->to_string;
847              
848             The JSON is generated with canonical set, so the keys are sorted,
849             meaning two calls against the same model will always produce identical
850             output, making it diffable.
851              
852             If token_splitter or stop_regex was set to a qr// Regexp, it is
853             stringified, so the result is always JSON safe.
854              
855             =cut
856              
857             sub to_string {
858 13     13 1 43 my ($self) = @_;
859              
860             # qr// Regexps can't be JSON encoded, so stringify them
861 13         16 my %model = %{ $self->{'model'} };
  13         109  
862 13         45 foreach my $regex_item ( 'token_splitter', 'stop_regex' ) {
863 26 100       57 if ( ref( $model{$regex_item} ) eq 'Regexp' ) {
864 1         3 $model{$regex_item} = '' . $model{$regex_item};
865             }
866             }
867              
868 13         59 return JSON::PP->new->encode( \%model );
869             } ## end sub to_string
870              
871             =head2 from_string
872              
873             Loads the model from the specified JSON string, replacing the current
874             model, including any settings passed to new for the object it is
875             being called on.
876              
877             $nb->from_string($json);
878              
879             Will die on failure to parse the string as JSON, if "format" in the
880             JSON is not the name of this module, if "version" is newer than the
881             supported model format version, or if the parsed JSON does not look
882             like a saved model.
883              
884             If it dies, the current model is left unchanged.
885              
886             =cut
887              
888             sub from_string {
889 47     47 1 11886 my ( $self, $raw ) = @_;
890              
891 47 100       107 if ( !defined($raw) ) {
892 1         7 die('No string specified');
893             }
894              
895 46         56 my $model = eval { JSON::PP->new->decode($raw) };
  46         222  
896 46 100       95958 if ( !defined($model) ) {
897 2         9 die( 'Failed to parse the string as JSON... ' . $@ );
898             }
899              
900 44 100       114 if ( ref($model) ne 'HASH' ) {
901 1         8 die('The string did not parse to a hash');
902             }
903 43 100 100     191 if ( !defined( $model->{'format'} ) || $model->{'format'} ne __PACKAGE__ ) {
904 2         15 die( '"format" is not "' . __PACKAGE__ . '"' );
905             }
906 41 100 66     241 if ( !defined( $model->{'version'} ) || $model->{'version'} !~ /^\d+$/ ) {
907 1         9 die('"version" is not a int');
908             }
909 40 100       112 if ( $model->{'version'} > $MODEL_VERSION ) {
910             die( '"version" is '
911 1         10 . $model->{'version'}
912             . ', which is newer than the highest supported model version of '
913             . $MODEL_VERSION );
914             }
915 39         110 foreach my $hash_item ( 'class_counts', 'token_counts', 'class_totals', 'tokens' ) {
916 153 100       281 if ( ref( $model->{$hash_item} ) ne 'HASH' ) {
917 1         9 die( '"' . $hash_item . '" is not a hash' );
918             }
919             }
920 38 100 66     155 if ( !defined( $model->{'total_docs'} ) || $model->{'total_docs'} !~ /\A\d+\z/ ) {
921 1         9 die('"total_docs" is not a whole number');
922             }
923 37 100 66     133 if ( !defined( $model->{'token_splitter'} ) || $model->{'token_splitter'} eq '' ) {
924 1         9 die('"token_splitter" is undef or a empty string');
925             }
926 36         53 foreach my $regex_item ( 'token_splitter', 'stop_regex' ) {
927 71 100 100     194 if ( defined( $model->{$regex_item} ) && !defined( eval { qr/$model->{$regex_item}/ } ) ) {
  37         321  
928 1         6 die( '"' . $regex_item . '" does not compile as a regex... ' . $@ );
929             }
930             }
931              
932             # default the optional tunables if missing
933 35 100       71 if ( !defined( $model->{'smoothing'} ) ) {
934 9         23 $model->{'smoothing'} = 'laplace';
935             }
936 35 100 100     97 if ( $model->{'smoothing'} ne 'laplace' && $model->{'smoothing'} ne 'lidstone' ) {
937 1         9 die('"smoothing" is not "laplace" or "lidstone"');
938             }
939 34 100       60 if ( !defined( $model->{'alpha'} ) ) {
940 9 50       28 $model->{'alpha'} = $model->{'smoothing'} eq 'lidstone' ? 0.5 : 1;
941             }
942 34 100 66     252 if ( ref( $model->{'alpha'} ) ne '' || $model->{'alpha'} !~ /\A\d*\.?\d+\z/ || $model->{'alpha'} <= 0 ) {
      100        
943 2         18 die('"alpha" is not a number greater than 0');
944             }
945 32 100 100     90 if ( $model->{'smoothing'} eq 'laplace' && $model->{'alpha'} != 1 ) {
946 1         10 die('"alpha" must be 1 when smoothing is "laplace"');
947             }
948              
949 31 100       940 if ( !defined( $model->{'ngrams'} ) ) {
950 7         11 $model->{'ngrams'} = 1;
951             }
952 31 100 66     180 if ( ref( $model->{'ngrams'} ) ne '' || $model->{'ngrams'} !~ /\A\d+\z/ || $model->{'ngrams'} < 1 ) {
      100        
953 2         23 die('"ngrams" is not a whole number greater than 0');
954             }
955              
956 29 100       54 if ( !defined( $model->{'token_weighting'} ) ) {
957 6         12 $model->{'token_weighting'} = 'count';
958             }
959 29 100 100     62 if ( $model->{'token_weighting'} ne 'count' && $model->{'token_weighting'} ne 'binary' ) {
960 1         9 die('"token_weighting" is not "count" or "binary"');
961             }
962              
963 28 100       48 if ( !defined( $model->{'priors'} ) ) {
964 5         7 $model->{'priors'} = 'trained';
965             }
966 28 100 100     64 if ( $model->{'priors'} ne 'trained' && $model->{'priors'} ne 'uniform' ) {
967 1         9 die('"priors" is not "trained" or "uniform"');
968             }
969              
970 27         152 $self->{'model'} = $model;
971             } ## end sub from_string
972              
973             =head2 save
974              
975             Saves the model to the specified file as JSON via to_string. The write
976             is done atomically, written to a temporary file and then renamed into
977             place, so the file will never contain a partially written model.
978              
979             $nb->save('model.json');
980              
981             Will die if no file is specified or on failure to write the file.
982              
983             =cut
984              
985             sub save {
986 12     12 1 1447 my ( $self, $file ) = @_;
987              
988 12 100       30 if ( !defined($file) ) {
989 1         9 die('No file specified');
990             }
991              
992 11         27 my $raw = $self->to_string;
993              
994 11         7038 eval { write_file( $file, { 'atomic' => 1, 'err_mode' => 'croak' }, $raw ); };
  11         58  
995 11 100       7875 if ($@) {
996 1         5 die( 'Failed to write "' . $file . '"... ' . $@ );
997             }
998             } ## end sub save
999              
1000             =head2 load
1001              
1002             Loads the model from the specified file via from_string, replacing the
1003             current model.
1004              
1005             $nb->load('model.json');
1006              
1007             Will die if no file is specified, on failure to read the file, failure
1008             to parse it as JSON, or if the parsed JSON does not look like a saved
1009             model.
1010              
1011             If it dies, the current model is left unchanged.
1012              
1013             =cut
1014              
1015             sub load {
1016 27     27 1 5943 my ( $self, $file ) = @_;
1017              
1018 27 100       77 if ( !defined($file) ) {
1019 1         11 die('No file specified');
1020             }
1021              
1022 26         41 my $raw = eval { read_file( $file, { 'err_mode' => 'croak' } ); };
  26         118  
1023 26 100       2988 if ( !defined($raw) ) {
1024 1         7 die( 'Failed to read "' . $file . '"... ' . $@ );
1025             }
1026              
1027 25         38 eval { $self->from_string($raw); };
  25         109  
1028 25 100       72 if ($@) {
1029 6         27 die( 'Failed to load the model from "' . $file . '"... ' . $@ );
1030             }
1031             } ## end sub load
1032              
1033             =head1 MODEL FORMAT
1034              
1035             The model as produced by to_string and save is a JSON hash like the
1036             below.
1037              
1038             {
1039             "format" : "Algorithm::Classifier::NaiveBayes",
1040             "version" : 1,
1041             "smoothing" : "laplace",
1042             "alpha" : 1,
1043             "ngrams" : 1,
1044             "token_weighting" : "count",
1045             "priors" : "trained",
1046             "class_counts" : {
1047             "ham" : 1,
1048             "spam" : 1
1049             },
1050             "class_totals" : {
1051             "ham" : 4,
1052             "spam" : 4
1053             },
1054             "token_counts" : {
1055             "ham" : {
1056             "at" : 1,
1057             "meeting" : 1,
1058             "noon" : 1,
1059             "tomorrow" : 1
1060             },
1061             "spam" : {
1062             "buy" : 1,
1063             "cheap" : 1,
1064             "now" : 1,
1065             "pills" : 1
1066             }
1067             },
1068             "tokens" : {
1069             "at" : 1,
1070             "buy" : 1,
1071             "cheap" : 1,
1072             "meeting" : 1,
1073             "noon" : 1,
1074             "now" : 1,
1075             "pills" : 1,
1076             "tomorrow" : 1
1077             },
1078             "total_docs" : 2,
1079             "lc_tokens" : 1,
1080             "token_splitter" : "\\s+",
1081             "stop_regex" : null
1082             }
1083              
1084             The keys are as below.
1085              
1086             format - The name of this module. Used by from_string to make sure
1087             the JSON is actually a saved model.
1088              
1089             version - The version of the model format. Currently 1. from_string
1090             will refuse to load a model with a version newer than it
1091             understands. Models missing any of the optional tunables,
1092             smoothing, alpha, ngrams, token_weighting, or priors, are
1093             loaded with those keys defaulted.
1094              
1095             class_counts - Per class count of how many documents have been
1096             trained.
1097              
1098             class_totals - Per class count of how many tokens have been
1099             trained.
1100              
1101             token_counts - Per class hash of token to how many times that
1102             token has been trained.
1103              
1104             tokens - A hash of every token trained across all classes. The
1105             size of this is the vocabulary size used for smoothing.
1106              
1107             total_docs - Total number of documents trained across all classes.
1108              
1109             lc_tokens, token_splitter, stop_regex, ngrams - The tokenizer
1110             settings as documented under new.
1111              
1112             smoothing, alpha - The smoothing settings as documented under new.
1113              
1114             token_weighting - The token weighting setting as documented under
1115             new.
1116              
1117             priors - The class prior setting as documented under new.
1118              
1119             =head1 AUTHOR
1120              
1121             Zane C. Bowers-Hadley, C<< >>
1122              
1123             =head1 BUGS
1124              
1125             Please report any bugs or feature requests to C, or through
1126             the web interface at L. I will be notified, and then you'll
1127             automatically be notified of progress on your bug as I make changes.
1128              
1129              
1130              
1131              
1132             =head1 SUPPORT
1133              
1134             You can find documentation for this module with the perldoc command.
1135              
1136             perldoc Algorithm::Classifier::NaiveBayes
1137              
1138              
1139             You can also look for information at:
1140              
1141             =over 4
1142              
1143             =item * RT: CPAN's request tracker (report bugs here)
1144              
1145             L
1146              
1147             =item * CPAN Ratings
1148              
1149             L
1150              
1151             =item * Search CPAN
1152              
1153             L
1154              
1155             =back
1156              
1157              
1158             =head1 ACKNOWLEDGEMENTS
1159              
1160              
1161             =head1 LICENSE AND COPYRIGHT
1162              
1163             This software is Copyright (c) 2026 by Zane C. Bowers-Hadley.
1164              
1165             This is free software, licensed under:
1166              
1167             The GNU Lesser General Public License, Version 2.1, February 1999
1168              
1169              
1170             =cut
1171              
1172             1; # End of Algorithm::Classifier::NaiveBayes