Go to main contentGo to footer
Rails
|
24 August 15

Machine Learning made simple with Ruby

How does automatic classification work without relying on external prediction services? Starting from Bayesian classifiers, we get to the Latent Semantic Indexer with the classifier-reborn gem. Hands on!

Lorenzo MasiniDeveloper

These days I'm working on a personal project where users can post listings with links on a platform.

A periodic task analyzes the links and downloads metadata such as title, description, photos, etc. using Open Graph tags (with a fallback in case they aren't specified).

At this point, the admin needs to be able to assign a category to the listing and publish it.

Classifiers

Admins of platforms like this typically have a ton of things to do and, let's be honest, manually assigning a category to every listing wouldn't exactly be a barrel of laughs.

So I wondered whether the system, after proper training, could at least automatically suggest a category that fits the submitted content.

Since listings always have to be categorized, introducing an automatic classifier wouldn't add to the admin's workload, even if it failed.

To achieve my goal, I thought of using a Bayesian classifier, and eventually chose the classifier-reborn gem. Reading the documentation of that gem, which implements several classification algorithms, I realized that one of them was better suited to my case than the Bayesian classifier: the Latent Semantic Indexer.

Latent Semantic Indexer

In practice, LSI is based on the principle that words used in the same context tend to have similar meanings.

Besides a method for classification, the gem also provides the inverse behavior, that is, searching for similar texts: a feature that could come in handy later.

Here's an example of how it works, taken from the README:

require 'classifier-reborn'
lsi = ClassifierReborn::LSI.new
strings = [ ["This text deals with dogs. Dogs.", :dog],
            ["This text involves dogs too. Dogs! ", :dog],
            ["This text revolves around cats. Cats.", :cat],
            ["This text also involves cats. Cats!", :cat],
            ["This text involves birds. Birds.",:bird ]]
strings.each {|x| lsi.add_item x.first, x.last}

lsi.search("dog", 3)
# returns => ["This text deals with dogs. Dogs.", "This text involves dogs too. Dogs! ",
#             "This text also involves cats. Cats!"]

lsi.find_related(strings[2], 2)
# returns => ["This text revolves around cats. Cats.", "This text also involves cats. Cats!"]

lsi.classify "This text is also about dogs!"
# returns => :dog

OK, decision made! Let's write some code!

Training

The LSI classifier needs training, that is, a way to associate a given text with a category. Since the system already contains listings that have been published and manually classified, building the training set is easy.

The training code will need to run periodically in an asynchronous task, since it's time-consuming.

Let's define the behavior of our Command Object through specs:

require 'rails_helper'

describe ClassifyAdvertisements do
  let(:command) { described_class.new(advertisements, training) }
  let(:advertisements) { [build(:advertisement, title: 'foo')] }
  let(:training) do
    [
      { title: 'foo baz', category_id: 1 },
    ]
  end
  let(:classifier) { instance_double('ClassifierReborn::LSI') }

  before do
    allow(ClassifierReborn::LSI).
      to receive(:new).
          and_return(classifier)

    allow(classifier).
      to receive(:add_item)
  end

  describe '#initialize' do
    it 'takes the advertisements to classify and the training as constructor parameters' do
      command
    end

    it 'trains the classifier with the training advertisements title and category id' do
      command
      expect(classifier).to have_received(:add_item).with('foo baz', 1)
    end
  end

  describe '#classify!' do
    let(:storer) { instance_double('StoreClassifications') }

    before do
      allow(StoreClassifications).
        to receive(:new).
            with(advertisements[0], classifier).
            and_return(storer)

        allow(storer).
          to receive(:execute!)
    end

    before { command.classify! }

    it 'classifies the advertisements' do
      expect(storer).to have_received(:execute!)
    end
  end
end

So the idea is to write a class that accepts, as constructor parameters, the listings to classify and a list of hashes with the keys title and category_id. The values of these hashes will be passed to the classifier for training.

The actual classification will be delegated to a second object, instantiated for each listing to classify, in the method #classify!.

For now, we can only imagine this object's interface: it will probably receive the listing to classify and the trained classifier.

Let's make the tests pass:

class ClassifyAdvertisements
  def initialize(advertisements, training)
    @advertisements = advertisements
    @training = training

    train_classifier!
  end

  def classify!
    advertisements.each do |advertisement|
      StoreClassifications.new(advertisement, classifier).execute!
    end
  end

  private

  attr_reader :advertisements, :training

  def train_classifier!
    training.each do |t|
      classifier.add_item(t[:title], t[:category_id])
    end
  end

  def classifier
    @classifier ||= ClassifierReborn::LSI.new
  end
end

Let's check that everything works:

$ bundle exec rspec spec/commands/classify_advertisements_spec.rb
...

Finished in 0.11143 seconds (files took 3.88 seconds to load)
3 examples, 0 failures

Perfect! Now we need to implement the object that handles the actual classification, saving the result to the database and updating the listing's status.

Classification

We already have an idea of its interface; now let's define its behavior through the spec:

require 'rails_helper'

describe StoreClassifications do
  let(:command) { described_class.new(advertisement, classifier) }
  let(:advertisement) { create(:advertisement, title: 'foo', category: nil) }
  let(:classifier) { instance_double('ClassifierReborn::LSI') }
  let!(:category) { create(:category) }

  it 'takes and advertisement and a classifier as constructor parameters' do
    command
  end

  describe '#execute!' do
    context 'on success' do
      before do
        allow(classifier).
          to receive(:classify).
              with('foo').
              and_return(category.id)
      end

      before { command.execute! }

      it 'updates the advertisement category' do
        expect(advertisement.reload.category_id).to eq(category.id)
      end

      it 'updates the status of the advertisement' do
        expect(advertisement.reload.classified?).to be(true)
      end
    end

    context 'if the classifier was not trained' do
      before do
        allow(classifier).
          to receive(:classify).
              with('foo').
              and_raise(Vector::ZeroVectorError)
      end

      before { command.execute! }

      it 'does not update the advertisement category' do
        expect(advertisement.reload.category_id).to be(nil)
      end

      it 'does not update the status of the advertisement' do
        expect(advertisement.reload.category_classified?).to be(false)
      end
    end
  end
end

And now the code to satisfy the tests:

class StoreClassifications
  def initialize(advertisement, classifier)
    @advertisement, @classifier = advertisement, classifier
  end

  def execute!
    ActiveRecord::Base.transaction do
      advertisement.update_attributes(category_id: classified_category_id)
      advertisement.classified!
    end
  rescue Vector::ZeroVectorError => e
    Rails.logger.error "error classifying advertisement #{advertisement.id}: #{e.message}"
  end

  private

  attr_reader :advertisement, :classifier

  def classified_category_id
    @classified_category_id ||= classifier.classify(advertisement.title)
  end
end

Let's check that everything is OK:

$ bundle exec rspec spec/commands/classify_advertisement_spec.rb
.....

Finished in 0.16245 seconds (files took 3.78 seconds to load)
5 examples, 0 failures

Conclusions

Let's see if it works:

pry(main)> foo_category = Category.create(name: 'foo')
=> #<Category:0x007fd9737ada10>

pry(main)> bar_category = Category.create(name: 'bar')
=> #<Category:0x007fd97770cb48>

pry(main)> training = [Advertisement.create(title: 'this is something about foo', category: foo_category), Advertisement.create(title: 'this is something about bar', category: bar_category)]
=> [#<Advertisement:0x007fd9778e61d0>, #<Advertisement:0x007fd9793aedc8>]

pry(main)> ad = Advertisement.create(title: "an article to be categorized talking about foo", category: nil)
=> #<Advertisement:0x007fd97a128490>

pry(main)> ClassifyAdvertisements.new([ad], training.map { |t| t.slice(:id, :title) }).classify!

pry(main)> ad.category.name
=> "foo"

I'm happy with the result of this implementation, although there are still some issues to tackle... for example: right now, training uses all the published listings every time the task runs. A better solution might be to save the classifier's data structure once training is done, updating it when the published listings change (synchronously or asynchronously).

footer