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!
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!
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 => :dogOK, 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
endSo 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
endLet'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
endAnd 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
endLet'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).