Vai al contenuto principaleVai al footer
rails
|
24 agosto 15

Machine Learning made simple with Ruby

Come funziona la classificazione automatica senza passare da servizi esterni di prediction? Partendo dai classificatori bayesiani, si arriva a Latent Semantic Indexer con la gemma classifier-reborn. Hands on!

Lorenzo MasiniDeveloper

In questo periodo sto lavorando su un progetto personale nel quale gli utenti possono inserire degli annunci con link su una piattaforma.

Un task eseguito periodicamente analizza i link e scarica i metadati come titolo, descrizione, foto ecc. utilizzando i tag opengraph (con possibilità di fallback nel caso questi non siano specificati).

A questo punto, l'amministratore deve poter assegnare una categoria all'annuncio e pubblicarlo.

Classificatori

Tipicamente gli admin delle piattaforme di questo genere hanno un sacco di cose da fare e — diciamoci la verità — l'assegnazione manuale di categorie ad ogni annuncio non sarebbe proprio il massimo del divertimento.

Mi sono quindi domandato se il sistema, dopo un'adeguato training, potesse quantomeno essere in grado di proporre automaticamente una categoria adeguata al contenuto inserito.

Poichè gli annunci devono essere sempre categorizzati, l'introduzione del classificatore automatico non appesentirebbe il lavoro dell'amministratore, anche in caso di fallimento.

Per raggiungere il mio scopo ho pensato di usare un _classificatore bayesiano_, scegliendo infine la gemma classifier-reborn. Leggendo la documentazione della suddetta gemma — che implementa diversi algoritmi di classificazione — mi sono accorto che uno di questi era piu' adatto al mio caso rispetto al Bayesian classifier. Si tratta del Latent Semantic Indexer.

Latent Semantic Indexer

Nella pratica, LSI si basa sul principio che le parole usate nello stesso contesto tendono ad avere un significato simile.

Oltre ad un metodo per offrire la classificazione, la gemma fornisce anche il comportamento l'inverso, ovvero la ricerca di testi simili: funzionalità che potrebbe risultare utile successivamente.

Ecco un esempio del suo funzionamento, tratto dal 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, scelta fatta! Iniziamo a scrivere un po' di codice!

Training

Il classificatore LSI ha bisogno di un training, ovvero un modo di associare un dato testo ad una categoria. La presenza nel sistema di annunci già pubblicati e classificati manualmente, il training è semplice da costruire.

La parte di codice relativa al training dovrà essere eseguita da un task avviato in asincrono, in quanto time-consuming, e periodicamente.

Definiamo il comportamento del nostro Command Object attraverso le spec:

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

L'idea quindi è quella di scrivere una classe che accetta, come parametri del costruttore, gli annunci da classificare e una lista di hash con chiavi title e category_id. I valori di questi hash dovranno essere passati al classificatore per il training.

La classificazione vera e propria verrà delegata ad un secondo oggetto, instanziato per ogni annuncio da classificare, nel metodo #classify!.

Di questo oggetto al momento ci possiamo immaginare solo l'interfaccia: probabilmente riceverà l'annuncio da classificare e il classificatore col training fatto.

Facciamo passare i test:

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

Verifichiamo che tutto vada bene:

$ 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

Perfetto! Ora dobbiamo implementare l'oggetto che si occupa della classificazione vera e propria, salvando il risultato sul database e aggiornando lo stato dell'annuncio.

Classificazione

Abbiamo già un'idea della sua interfaccia; definiamone ora il comportamento attraverso lo 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

E ora il codice per soddisfare i test:

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

Verifichiamo che tutto sia 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

Conclusioni

Vediamo se funziona:

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"

Sono soddisfatto del risultato raggiunto con questa implementazione, anche se rimangono ancora alcune problematiche da affrontare... ad esempio: attualmente il training viene fatto utilizzando tutti gli annunci pubblicati, ogni volta che viene eseguito il task. Una soluzione migliore potrebbe essere quella di salvare la struttura dati del classificatore una volta eseguito il training, aggiornandolo quando gli annunci pubblicati variano (in modo sincrono o asincrono).