In questa seconda parte, analizziamo il modo in cui il codice sorgente di WEKA possa essere integrato in un‘applicazione Java-based per il Data Mining, finalizzata alla classificazione di dati meteo. L‘interfaccia grafica utilizza componenti Swing ed è sviluppata secondo un approccio event-driven.
Nel primo articolo di questa serie è stato dettagliatamente descritto il modo in cui è stato utilizzato il framework WEKA per realizzare il processo di Knowledge Discovery in Databases, applicato a dati meteo, finalizzato alla classificazione di eventi di nebbia.
WEKA permette, principalmente, di creare e di testare modelli di Data Mining, ma non di utilizzarli per classificare record per i quali non sia nota la classe di appartenenza.
Il presente articolo illustra come integrare la libreria del codice di WEKA nelle proprie applicazioni, utilizzando la documentazione che mostra come usare l’interfaccia programmabile (API), che WEKA espone attraverso le proprie classi [2] [3].
In particolare, si pone l’interesse all’insieme di funzioni, procedure, variabili e strutture dati, che definiscono e permettono di utilizzare il modello per la predizione di valori, ossia una funzione che ha “appreso” come assegnare, a ogni oggetto di una collezione, una classe. D’ora in avanti si intenderà con il termine “classificazione” l’attività di assegnazione della classe, col termine “modello” (secondo WEKA) l’output dell’algoritmo addestrato su un opportuno dataset, e col termine “dataset” la collezione di oggetti da classificare (i dati secondo WEKA).
Requisiti dell’applicazione
Si intende realizzare un prototipo applicativo che utilizzi un modello di classificazione generato con WEKA allo scopo di assegnare una classe a ciascuna istanza di un dataset (formato da dati non classificati, ovvero per i quali i valori in corrispondenza dell’attributo di classe sono mancanti), con la relativa probabilità di appartenenza alla classe assegnata. Sia il modello che i dati sono gli input dell’applicazione; l’output è costituito da un file di dati in formato ARFF (Attribute-Relationship File Format), corredato dalle classi di appartenenza e le probabilità.
Figura 1 – Schema di elaborazione dell’applicazione
I requisiti di utilizzo dell’applicazione riguardano il modello di classificazione e i dati da classificare. Il file che rappresenta il modello (con estensione .model) è stato generato attraverso il framework WEKA, come descritto nel primo articolo della serie, e memorizzato in nel file system.
L’insieme dei dati da classificare deve essere disponibile in formato ARFF. Questo rappresenta in modo testuale (in codifica ASCII) una lista di istanze che condividono un insieme di attributi. È, dunque, simile al più noto CSV (Comma Separated Values) e, per certi versi, equivalente alla tabella di un database relazionale. Il formato prevede due sezioni distinte, quella di intestazione (header) e quella dei dati. Il file di input deve avere header identico al dataset con cui il modello è stato addestrato.
Nell’intestazione vengono definiti il nome della relazione e una lista degli attributi (le colonne della tabella dei dati) con i relativi tipi nella forma seguente (l’unica attenzione da prestare è quella di includere le stringhe fra apici, laddove queste includano degli spazi).
@RELATION @ATTRIBUTE ... @ATTRIBUTE {, , ...} ... @DATA , , ..., ...
In particolare, l’ultimo attributo è convenzionalmente destinato alla classificazione, ossia definisce le possibili classi di appartenenza dei record della relazione.
Si noti che le dichiarazioni @relation, @attribute e @data sono case-insensitive.
Quanto ai tipi di dato, WEKA supporta valori numerici (reali o interi), valori specificati nominalmente (definiti fornendo una lista di possibili valori nominali).
Nella sezione successiva @DATA si trovano i dati, ossia le successioni di valori (uno per ciascun attributo), che costituiscono le singole istanze dei dati. Ogni istanza è rappresentata su una singola riga in cui i valori degli attributi, opportunamente separati da virgole, devono rispettare lo stesso ordine con cui questi sono stati dichiarati nell’intestazione, mentre un ritorno a capo denota la fine dell’istanza.
Disegno dell’applicazione
Per disegnare l’architettura dell’applicazione si può ricorrere a un pattern che ne semplifichi la definizione, consentendo di impostare rapidamente l’organizzazione della struttura del software.
Il noto pattern Model-View-Controller (MVC) prevede di separare i componenti software che implementano il modello delle funzionalità di business (model) dai componenti che implementano la logica di presentazione (view) e da quelli di controllo (controller), che utilizzano le funzionalità suddette.
È stato scelto di seguire il paradigma UseCase Controller, che suggerisce di creare un controllore per ogni caso d’uso previsto, ossia un oggetto responsabile di gestire le richieste dello strato UI provenienti da quella parte di interfaccia destinata a quel determinato caso d’uso.
La separazione suddetta consente un’indipendenza dalla logica di presentazione che potrà essere sviluppata secondo il proprio gusto, purche’ implementi lo strato di controllo dei casi d’uso.
Si utilizza UML in modo piuttosto pragmatico per la rappresentazione del software. Vengono mostrati i casi d’uso riguardanti il modo in cui dovrebbe funzionare l’applicazione.
Figura 2 – Diagramma dei casi d’uso.
Si prosegue definendo un diagramma iniziale delle classi, che guida nello sviluppo di tutta l’applicazione.
Figura 3 – Diagramma iniziale delle classi.
Logica di business
La logica di business dell’applicazione è contenuta nelle classi WekaClassifier e WekaDataset. La prima sfrutta il core di WEKA per elaborare i dati, esponendo un apposito metodo. Il suo costruttore dovrà istanziare un classificatore sulla base di un determinato file in cui è definito il modello da impiegare allo scopo. In effetti, WEKA permette di riutilizzare i modelli consentendo di salvare gli stessi su un disco del proprio computer. Questo meccanismo di persistenza si basa sul concetto di “serializzazione” degli oggetti Java, che prevede un processo di trasformazione degli oggetti in uno stream di byte che può essere memorizzato nel file system. Perciò la prima operazione da fare per poter utilizzare un modello WEKA è l’operazione inversa, detta “deserializzazione” (o ripristino), cioè la ricostruzione degli oggetti corrispondenti a uno stream di byte preventivamente memorizzato.
Un oggetto può essere ripristinato usando il metodo readObject() della classe ObjectInputStream. Tuttavia, a partire dalla versione 3.5.5 di WEKA esiste un modo ancora più comodo per raggiungere il risultato, che fa uso della classe SerializationHelper, come di seguito illustrato. Quest’operazione può lanciare un’eccezione che dovrà essere gestita nella classe che crea il modello di classificatore.
Qui di seguito si presenta un estratto del codice della classe WekaClassifier, che utilizza le classi Classifier del package weka.classifiers [3] e SerializationHelper del package weka.core [3].
import weka.classifiers.Classifier; import weka.core.SerializationHelper; public class WekaClassifier { // Instance variables. private Classifier classifier; /** * Constructor for objects of class WekaClassifier. */ public WekaClassifier(String filename) throws Exception { // Deserialize the model. classifier = (Classifier) SerializationHelper.read(filename); } ...
Così facendo un’istanza del modello (classifier) è pronta per essere applicata al dataset che si intende classificare. Prima di arrivare a questo, però, è necessario che anche tutti gli oggetti contenuti in quest’ultimo (i dati, sostanzialmente) siano immagazzinati correttamente nella memoria centrale del computer.
Per tale scopo è stata ideata la classe WekaDataset, che può contenere il dataset da classificare e fornisce un semplice metodo per la persistenza di questo in seguito alla elaborazione.
Il suo costruttore di base si servirà di un flat file (con estensione .arff) contenente l’intera struttura, che dovrà essere letta e caricata in memoria con un’operazione di lettura da file. Ciò che si ottiene è, dunque, un insieme di istanze (instances) che rappresentano i dati (o, se si vuole, i record) da elaborare, che conviene mantenere come variabile di istanza della classe WekaDataset. Quest’operazione di lettura da file può lanciare eccezioni del tipo FileNotFoundException e IOException, che saranno gestite nella classe che istanzia il dataset Weka.
La prima cosa importante da fare sarà definire subito l’attributo di classe. Essendo gli attributi memorizzati in un apposito vettore, sarà sufficiente individuarne l’indice corrispondente. Inoltre, essendo l’attributo di classe, per convenzione, l’ultimo della lista, questo indice sarà sempre pari al numero di attributi meno 1 (partendo gli indici da 0).
Successivamente, si può allocare in memoria l’altra variabile di istanza, distroForInstance, pensata per conservare la distribuzione di probabilità dei valori che possono essere assegnati all’attributo da classificare.
Il codice che segue mostra i costruttori per la classe WekaDataset, che fa uso di alcune classi base di IO di Java e della classe Instances del package weka.core [3].
import java.io.BufferedReader; import java.io.FileNotFoundException; import java.io.FileReader; import java.io.IOException; import weka.core.Instances; public class WekaDataset { // Instance variables. private Instances instances; private double[][] distroForInstance; /** * Constructor for objects of class WekaDataset from a given file. */ public WekaDataset(String filename) throws FileNotFoundException, IOException { // Load instances from the file. instances = new Instances( new BufferedReader(new FileReader(filename))); // Set the class attribute. instances.setClassIndex(instances.numAttributes() - 1); // Allow the array for distribution. distroForInstance = new double[instances.numInstances()][instances.numClasses()]; } ...
Come si potrà osservare in seguito, è necessario anche un altro costruttore per la classe WekaDataset, che genererà una copia di un dataset già istanziato.
/** * Constructor for objects of class WekaDataset from an existing dataset. */ public WekaDataset(WekaDataset dataset) { // Copy instances from another dataset. instances = new Instances(dataset.instances); // Set the class attribute. instances.setClassIndex(instances.numAttributes() - 1); // Allow the array for distribution. distroForInstance = new double[instances.numInstances()][instances.numClasses()]; }
A questo punto sono presenti in memoria sia il modello (classificatore), sia la struttura dei dati e, dunque, si può procedere con l’elaborazione vera e propria.
Come anticipato, per svolgere comodamente tutte le operazioni si utilizza una copia di lavoro del dataset, indicata col nome unlabeled per distinguerla da tolabel che, invece, rappresenta l’insieme da classificare (ovvero, a cui assegnare delle “etichette” di classe). Quindi, per ciascuna istanza si applica il metodo di classificazione del modello prescelto.
Il modello fornito dall’utente per la classificazione del dataset utilizza la propria struttura interna per predire l’attribuzione delle classi e le corrispondenti probabilità. Ogni classificatore WEKA deve implementare almeno un metodo fra classifyInstance e distributionForInstance [3]. In questo articolo verranno utilizzati entrambi.
In particolare, il metodo classifyInstance restituisce il valore di classe assegnato dal classificatore a una data istanza. I valori delle classi nominali sono memorizzati in variabili di tipo double, che rappresentano l’indice del nome del valore nella dichiarazione corrente. Il metodo distributionForInstance, invece, restituisce la distribuzione di probabilità di appartenenza di quell’istanza su tutte le classi possibili. La probabilità più alta designa la classe di assegnamento. Entrambi i metodi suddetti possono lanciare delle eccezioni che dovranno essere gestite nella classe che li richiama.
/* Classifies an unlabeled dataset with the trained model. */ public void classify(WekaDataset tolabel) throws Exception { // Create a copy for manipulating the dataset. WekaDataset unlabeled = new WekaDataset(tolabel); // Label the instances and get the distribution. double clsVal = 0; double[] clsDistro = null; for (int i=0; i<unlabeled.numInstances(); i++) { clsVal = classifier.classifyInstance(unlabeled.getInstance(i)); clsDistro = classifier.distributionForInstance(unlabeled.getInstance(i)); tolabel.setDistroForInstanceAt(clsDistro, i); tolabel.setClassValueAt(clsVal, i); } }
Si noti che questo metodo implementativo fa riferimento a tutta una serie di metodi della classe WekaDataset che servono per operare sulle istanze del dataset, tra cui numInstances (che restituisce il numero di istanze presenti), getInstance (che preleva l’istanza che si trova in una data posizione della collezione), setClassValueAt (che assegna a una data istanza un dato valore di classe), setDistroForInstanceAt (che assegna a una data istanza le probabilità di appartenenza alle varie classi). Il codice di questi metodi fa a sua volta uso delle classi Weka Instances e Instance (package weka.core), che servono per manipolare le istanze del dataset [3].
/* Returns the number of instances in the dataset. */ public int numInstances() { return instances.numInstances(); } /* Returns the instance at the given position. */ public Instance getInstance(int index) { return instances.instance(index); } /* Sets a class value for the instance at the given position. */ public void setClassValueAt(double value, int index) { instances.instance(index).setClassValue(value); } /* Sets the class memberships for the instance at the given position. */ public void setDistroForInstanceAt(double[] distribution, int index) { for(int i=0; i<distribution.length; i++) distroForInstance[index][i] = new Double(distribution[i]); }
Oltre ai suddetti la classe WekaDataset dovrà prevedere anche un metodo per convertire in stringa la distribuzione delle probabilità e che servirà per fornire un output immediato dell’elaborazione all’utente. Questo metodo usa la classe Attribute del package weka.core [3] per operare sull’attributo di classe del nostro dataset, così come mostrato di seguito.
/* Returns a string with distribution for all the instances. */ public String distroToString() { StringBuffer sb = new StringBuffer(); Attribute classAttribute = instances.classAttribute(); for (int i = 0; i < instances.numInstances(); i++) { sb.append(new String("Instance " + (i+1) + ": " )); for (int j = 0; j < classAttribute.numValues(); j++) { sb.append(classAttribute.value(j).toUpperCase() + " (" + distroForInstance[i][j] + ") "); } sb.append(" "); } return sb.toString(); }
Per finire è necessario salvare il lavoro svolto (le istanze così classificate) in un apposito file con lo stesso formato di quello di origine. Come previsto la responsabilità dell’output su un file è affidata alla classe WekaDataset, che possiede il metodo save, il quale riceve il nome del file come parametro e lo apre per scriverci dentro il dataset nello stato in cui si trova in quel momento. Nella fattispecie sarà uguale a quello di partenza opportunamente completato con le classi di appartenenza.
Questo metodo fa uso delle classi BufferedWriter e FileWriter del package java.io e, dunque, può generare un’eccezione di tipo IOException, che dovrà essere gestita all’interno della classe che utilizza il dataset Weka.
/* Save the instances to a file. */ public void save(String filename) throws IOException { BufferedWriter writer = new BufferedWriter(new FileWriter(filename)); writer.write(instances.toString()); riter.newLine(); writer.flush(); writer.close(); }
Infine, per la realizzazione dell’interfaccia grafica è stato utilizzato uno strumento di sviluppo visuale, uno dei tanti disponibili sia in forma integrata (IDE) sia come tool a s��� stanti.
La grafica utilizza componenti (widget) Swing, che per loro natura sono “leggeri” (cioè, portabili su tutte le piattaforme) e consentono di ottenere un accurato controllo del rendering. Inoltre, è anche possibile modificare agilmente il look and feel dell’applicazione, impostando, ad esempio, l’aspetto nativo di Windows XP.
In particolare, sono stati previsti dei campi di testo per l’individuazione dei file da parte dell’utente; accanto a ciascuno di essi, c’è un pulsante che richiama un’istanza della classe JFileChooser per selezionare il file di interesse attraverso il file system. Un altro pulsante, poi, fa partire il processo di classificazione: si registra un ascoltatore dell’evento (action) collegato al click del mouse da parte dell’utente; quindi, il metodo invocato dà origine alle istanze delle classi WekaClassifier e WekaDataset, che svolgono tutti i compiti per cui sono state implementate.
Un’apposita area di testo, infine, è destinata a mostrare all’utente la distribuzione delle probabilità di classificazione delle varie istanze del dataset elaborato.
Figura 4 – Funzionamento dell’applicazione.
Conclusioni
In quest’articolo è stato descritto come realizzare un’applicazione Java, basata sull’impiego di un potente e flessibile strumento per il data mining quale è WEKA, che esegue la classificazione di record di dati, ossia l’assegnazione a ciascuno di essi di una classe di appartenenza.
Riferimenti
[1] WEKA home site
http://www.cs.waikato.ac.nz/ml/weka/
[2] WEKA-Sourceforge site
http://weka.wiki.sourceforge.net/
[3] WEKA API Javadoc site
http://weka.sourceforge.net/doc/