Algorithm-Classifier-NaiveBayes

 view release on metacpan or  search on metacpan

lib/Algorithm/Classifier/NaiveBayes.pm  view on Meta::CPAN

package Algorithm::Classifier::NaiveBayes;

use 5.006;
use strict;
use warnings;
use JSON::PP    ();
use File::Slurp qw(read_file write_file);

=head1 NAME

Algorithm::Classifier::NaiveBayes - A multinomial naive Bayes text classifier with Laplace smoothing.

=head1 VERSION

Version 0.0.1

=cut

our $VERSION = '0.0.1';

# version of the saved model format
our $MODEL_VERSION = 1;

=head1 SYNOPSIS

    use Algorithm::Classifier::NaiveBayes;

    my $nb = Algorithm::Classifier::NaiveBayes->new;

    # train it with examples of each class
    $nb->train( 'spam', 'buy cheap pills now' );
    $nb->train( 'spam', 'cheap watches for sale' );
    $nb->train( 'ham',  'meeting at noon tomorrow' );
    $nb->train( 'ham',  'lunch with the team' );

    # classify some new text
    my $class = $nb->classify('cheap pills for sale');
    # $class is now 'spam'

    # or get the score and probability for every class as well
    my ( $best, $scores, $probs ) = $nb->classify('cheap pills for sale');

    # save the model for later and load it again
    $nb->save('model.json');

    my $loaded = Algorithm::Classifier::NaiveBayes->new;
    $loaded->load('model.json');

=head1 DESCRIPTION

This module implements a multinomial naive Bayes classifier. Strings
are broken into tokens and each class is scored using the log of its
prior probability, based on how often the class was trained, plus the
sum of the log probabilities of each token appearing in that class.
Token probabilities are smoothed so tokens never seen for a class do
not zero out the whole score. By default this is add-one, Laplace,
smoothing, but Lidstone, add-alpha, smoothing with a configurable
alpha may be selected instead. Smaller alphas, such as 0.1 to 0.5,
often perform better on small training sets.

By default token occurrences are weighted by their raw counts, but
binary weighting, counting each unique token once per document, may
be selected instead via token_weighting. Class priors default to how
often each class was trained, but may be set to uniform via priors.

Classes are not predefined. A class exists once something has been
trained for it and stops existing if everything for it is untrained.

The model may be saved to a JSON file or string and loaded back later,
allowing training and classification to happen in different processes.

=head1 METHODS

=head2 new

Initiates the object.

    my $nb = Algorithm::Classifier::NaiveBayes->new(%args);

The following args are supported.

    lc_tokens - Lowercase tokens when tokenizing.
        Default: 1

    token_splitter - Regex to use for splitting a string into tokens.
        Default: \s+

    stop_regex - If defined, tokens matching this regex are dropped.
        Matched anchored, so it must match the entire token.
        Default: undef

    smoothing - The smoothing to use for token probabilities. Either
        "laplace", add-one, or "lidstone", add-alpha.
        Default: laplace

    alpha - The alpha to use for lidstone smoothing. Must be a number
        greater than 0. May only be specified when smoothing is set to
        lidstone. Laplace smoothing is lidstone with a alpha of 1.
        Default: 0.5

    ngrams - Max size of n-grams to generate from adjacent tokens when

lib/Algorithm/Classifier/NaiveBayes.pm  view on Meta::CPAN


In scalar context, returns the name of the class the text most likely
belongs to. In list context, also returns a hash ref of the score for
every class as well as a hash ref of the probability of every class.

    my ( $class, $scores, $probs ) = $nb->classify($text);
    foreach my $possible ( sort { $scores->{$b} <=> $scores->{$a} } keys %{$scores} ) {
        print $possible . ': ' . $scores->{$possible} . ', ' . $probs->{$possible} . "\n";
    }

The scores are log probabilities, so they are negative numbers with
the one closest to zero being the most likely.

The probabilities are the scores normalized to sum to 1, so they may
be used for things like requiring a minimum confidence.

    my ( $class, $scores, $probs ) = $nb->classify($text);
    if ( $probs->{$class} < 0.8 ) {
        $class = 'unsure';
    }

It is worth noting naive Bayes probabilities tend to be overconfident
thanks to the assumption tokens are independent of each other, with
longer texts commonly producing probabilities very close to 1 or 0.
They are good for ranking and thresholding, but should not be taken
as calibrated probabilities.

If nothing has been trained yet, undef is returned in scalar context
and ( undef, {}, {} ) in list context.

Ties are broken by sorting the tied class names, making the result
deterministic.

=cut

sub classify {
	my ( $self, $text ) = @_;

	if ( $self->{'model'}{'total_docs'} < 1 ) {
		return wantarray ? ( undef, {}, {} ) : undef;
	}

	my @tokens     = $self->_weighted_tokens( $self->tokenize($text) );
	my $token_size = scalar keys %{ $self->{'model'}{'tokens'} };
	my $alpha      = defined( $self->{'model'}{'alpha'} ) ? $self->{'model'}{'alpha'} : 1;

	my %scores;
	for my $class ( keys %{ $self->{'model'}{'class_counts'} } ) {
		my $log_prob = $self->_log_prior($class);
		my $total    = $self->{'model'}{'class_totals'}{$class} || 0;

		if ( ( $total + ( $alpha * $token_size ) ) > 0 ) {
			for my $token (@tokens) {
				my $count = $self->{'model'}{'token_counts'}{$class}{$token} || 0;
				$log_prob += log( ( $count + $alpha ) / ( $total + ( $alpha * $token_size ) ) );
			}
		}
		$scores{$class} = $log_prob;
	} ## end for my $class ( keys %{ $self->{'model'}{'class_counts'...}})

	my ($best) = sort { $scores{$b} <=> $scores{$a} || $a cmp $b } keys %scores;

	if ( !wantarray ) {
		return $best;
	}

	# normalize the log scores into probabilities, shifting by the max
	# so exp does not underflow for large negative log scores
	my $max = $scores{$best};
	my %probs;
	my $prob_sum = 0;
	for my $class ( keys %scores ) {
		$probs{$class} = exp( $scores{$class} - $max );
		$prob_sum += $probs{$class};
	}
	for my $class ( keys %probs ) {
		$probs{$class} = $probs{$class} / $prob_sum;
	}

	return ( $best, \%scores, \%probs );
} ## end sub classify

=head2 explain

Classifies the text in question like classify, but returns a hash ref
breaking down how the result was arrived at.

    my $explanation = $nb->explain($text);

The returned hash ref is as below.

    class - The best matching class, as classify would return.

    scores - Hash ref of the log score of every class, as classify
        would return.

    probs - Hash ref of the probability of every class, as classify
        would return.

    priors - Hash ref of the log prior probability of every class,
        the part of the score that comes from how often the class was
        trained rather than from the tokens.

    tokens - Hash ref of every token in the tokenized text. Each value
        is a hash ref with "count", how many times the token appeared
        in the text, and "contributions", a hash ref of the log
        probability that token added to each class per appearance.

For any class, the score is the prior plus count * contribution summed
over every token. A token pushes towards the class it has the highest,
closest to zero, contribution for. So finding the tokens most
responsible for a classification can be done like below.

    my $explanation = $nb->explain($text);
    my ( $first, $second ) =
        sort { $explanation->{'scores'}{$b} <=> $explanation->{'scores'}{$a} }
        keys %{ $explanation->{'scores'} };
    foreach my $token ( keys %{ $explanation->{'tokens'} } ) {
        my $contribs = $explanation->{'tokens'}{$token}{'contributions'};
        my $pull = ( $contribs->{$first} - $contribs->{$second} )
            * $explanation->{'tokens'}{$token}{'count'};
        print $token . ' pushed towards ' . $first . ' by ' . $pull . "\n";
    }

Will die if the text is undef. If nothing has been trained yet, undef
is returned.

=cut

sub explain {
	my ( $self, $text ) = @_;

	if ( !defined($text) ) {
		die('No text specified');
	}

	if ( $self->{'model'}{'total_docs'} < 1 ) {
		return undef;
	}

	my @tokens     = $self->_weighted_tokens( $self->tokenize($text) );
	my $token_size = scalar keys %{ $self->{'model'}{'tokens'} };
	my $alpha      = defined( $self->{'model'}{'alpha'} ) ? $self->{'model'}{'alpha'} : 1;

	my %text_counts;
	foreach my $token (@tokens) {
		$text_counts{$token}++;
	}

	my %priors;
	my %scores;
	my %token_info;
	for my $class ( keys %{ $self->{'model'}{'class_counts'} } ) {
		$priors{$class} = $self->_log_prior($class);
		my $log_prob = $priors{$class};
		my $total    = $self->{'model'}{'class_totals'}{$class} || 0;
		my $denom    = $total + ( $alpha * $token_size );

		if ( $denom > 0 ) {
			foreach my $token ( keys %text_counts ) {
				my $count        = $self->{'model'}{'token_counts'}{$class}{$token} || 0;
				my $contribution = log( ( $count + $alpha ) / $denom );
				$token_info{$token}{'count'} = $text_counts{$token};
				$token_info{$token}{'contributions'}{$class} = $contribution;
				$log_prob += $contribution * $text_counts{$token};
			}
		}
		$scores{$class} = $log_prob;
	} ## end for my $class ( keys %{ $self->{'model'}{'class_counts'...}})

	my ($best) = sort { $scores{$b} <=> $scores{$a} || $a cmp $b } keys %scores;

	my $max = $scores{$best};
	my %probs;
	my $prob_sum = 0;
	for my $class ( keys %scores ) {
		$probs{$class} = exp( $scores{$class} - $max );
		$prob_sum += $probs{$class};
	}
	for my $class ( keys %probs ) {
		$probs{$class} = $probs{$class} / $prob_sum;
	}

	return {
		'class'  => $best,
		'scores' => \%scores,
		'probs'  => \%probs,
		'priors' => \%priors,
		'tokens' => \%token_info,
	};
} ## end sub explain

=head2 tweak

Changes scoring settings on a existing model. Takes the args below,
all optional, but at least one must be specified.

    smoothing - The smoothing to use... laplace or lidstone.

    alpha - The alpha to use for lidstone smoothing. Must be a number
        greater than 0. May only be specified when the resulting
        smoothing is lidstone.

    priors - How class priors are computed... trained or uniform.

    # switch to lidstone smoothing with a alpha of 0.1
    $nb->tweak( 'smoothing' => 'lidstone', 'alpha' => 0.1 );

    # switch to uniform priors
    $nb->tweak( 'priors' => 'uniform' );

These are safe to change after training as they only affect scoring,
not the trained counts. Settings that shape the trained data, such as
ngrams, token_weighting, and the tokenizer settings, may not be
changed here as that would make the model inconsistent with what was
trained... for those, create a new object and retrain.

Only args specified with a defined value are changed. Args passed
with a undef value are ignored, so it is safe to pass through
possibly unset values.

Switching smoothing to laplace sets alpha to 1, as laplace is add-one.
Switching to lidstone without specifying alpha keeps the current
alpha.

Will die if passed a unknown arg, no args with defined values, or a
insane value. If it dies, the model is left unchanged.

=cut

sub tweak {
	my ( $self, %args ) = @_;

	my %known_args = ( 'smoothing' => 1, 'alpha' => 1, 'priors' => 1 );
	foreach my $arg ( keys %args ) {
		if ( !defined( $known_args{$arg} ) ) {
			die( '"' . $arg . '" is not a known arg' );
		}
	}
	if ( !grep { defined( $args{$_} ) } keys %args ) {
		die('No args specified');
	}

	# validate against what the settings would become
	my $smoothing = defined( $args{'smoothing'} ) ? $args{'smoothing'} : $self->{'model'}{'smoothing'};



( run in 0.988 second using v1.01-cache-2.11-cpan-9581c071862 )