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!
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!
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).