Subscribe:

Pages

Using the UCI Datasets with jRuby and WEKA

The UCI Machine Learning Repository is one address which should be in every machine-learner's Bookmarks list. The repository (as of now!) holds 177 datasets which have been used as applications and to test machine learning and data-mining algorithms for several decades. Some of the datasets are relatively small and well explored, suitable for evaluating a new algorithm, but some of the datasets are large, and present a real data-mining challenge. For instance, the Soybean dataset was contributed in 1988 and has around 600 instances in total, whereas the Poker Hand dataset was contributed in 2007 and has over a million instances. There are datasets covering a wide variety of applications, from classification of attribute-value data, such as the Mushroom dataset, to regression on numeric data, such as the Auto MPG dataset. My favourites are the various Molecular Biology datasets and the subset of the NASA dataset of Volcanoes on Venus.

Working with these datasets is a useful exercise to learn about different machine-learning applications, and particularly for exploring the capabilities of learning algorithms with which you are unfamiliar. You can compare your own experimental results with those of previous researchers, and make sure you are using the algorithms correctly. Also, if and when you create your own learning algorithm, you can evaluate its performance on these standard datasets, and hence compare it with previous algorithms.

However, the format of the datasets is not consistent, although most of the data can be viewed in standard text editors. Before we can begin exploring these datasets within a toolkit such as WEKA, we must convert the data into a format that WEKA can understand. One way would be to write a tool to convert the original data format into WEKA's ARFF file format, and then work with the graphical tool. A different way, which follows on from my previous post, is to use jRuby to talk directly to WEKA; jRuby can then be used to read in and analyse the custom data format, before developing and evaluating machine-learning algorithms through the WEKA API.

Reading UCI Datasets

Using WEKA with files like the UCI datasets means converting an external data format into the internal format used within WEKA. As I covered in my previous post, WEKA instances must be constructed using a set of Attribute instances, where each Attribute describes its name, type (numeric or nominal) and the range of values it can hold, if nominal. Setting up these attributes is the tedious step. You have to look in the description of the data, and extract the attributes names, type and values, then code it into Ruby. The following was created using information from the 'names' file of the Mushroom dataset, to create an array of Attributes and a Label attribute:

# Convert provided arguments into a FastVector
# -- use mainly in making nominal attributes
def make_vector *args
atts = FastVector.new(args.length)
args.each {|arg| atts.addElement arg}
return atts
end

# == Create the attributes

Attributes = [
Attribute.new("cap-shape", make_vector("b", "c", "x", "f", "k", "s")),
Attribute.new("cap_surface", make_vector("f", "g", "y", "s")),
Attribute.new("cap_colour", make_vector("n", "b", "c", "g", "r", "p", "u", "e", "w", "y")),
Attribute.new("bruises?", make_vector("t", "f")),
Attribute.new("odour", make_vector("a", "l", "c", "y", "f", "m", "n", "p", "s")),
Attribute.new("gill-attachment", make_vector("a", "d", "f", "n")),
Attribute.new("gill-spacing", make_vector("c", "w", "d")),
Attribute.new("gill-size", make_vector("b", "n")),
Attribute.new("gill-colour", make_vector("k", "n", "b", "h", "g", "r", "o", "p", "u", "e", "w", "y")),
Attribute.new("stalk-shape", make_vector("e", "t")),
Attribute.new("stalk-root", make_vector("b", "c", "u", "e", "z", "r", "?")),
Attribute.new("stalk-surface-above-ring", make_vector("f", "y", "k", "s")),
Attribute.new("stalk-surface-below-ring", make_vector("f", "y", "k", "s")),
Attribute.new("stalk-colour-above-ring", make_vector("n", "b", "c", "g", "o", "p", "e", "w", "y")),
Attribute.new("stalk-colour-below-ring", make_vector("n", "b", "c", "g", "o", "p", "e", "w", "y")),
Attribute.new("veil-type", make_vector("p", "u")),
Attribute.new("veil-colour", make_vector("n", "o", "w", "y")),
Attribute.new("ring-number", make_vector("n", "o", "t")),
Attribute.new("ring-type", make_vector("c", "e", "f", "l", "n", "p", "s", "z")),
Attribute.new("spore-print-colour", make_vector("k", "n", "b", "h", "r", "o", "u", "w", "y")),
Attribute.new("population", make_vector("a", "c", "n", "s", "v", "y")),
Attribute.new("habitat", make_vector("g", "l", "m", "p", "u", "w", "d"))
]
Label = Attribute.new("class", make_vector("p", "e"))

The 'data' file of the Mushroom dataset contains lines of characters, in the following format:

p,x,s,n,t,p,f,c,n,k,e,e,s,s,w,w,p,w,o,p,k,s,u
e,x,s,y,t,a,f,c,b,k,e,c,s,s,w,w,p,w,o,p,n,n,g
e,b,s,w,t,l,f,c,b,n,e,c,s,s,w,w,p,w,o,p,n,n,m
p,x,y,w,t,p,f,c,n,n,e,e,s,s,w,w,p,w,o,p,k,s,u
e,x,s,g,f,n,f,w,b,k,t,e,s,s,w,w,p,w,o,e,n,a,g

Each line represents one instance, which later we will need to convert into a WEKA instance. The trick with the WEKA instances is that each must be linked to the dataset in which it lies. When I load the data, I don't know which dataset I will want each line to be added to. So, I construct instances of the following class as I read in the data. The class has a method which will build an instance and put it in the appropriate dataset, when called.

class UciInstance
attr_reader :features, :label
def initialize(features, class_label)
@features = features
@label = class_label
end

# Convert raw data into WEKA Instance and add to dataset
# -- assumes features.length == Attributes.length
def make_instance dataset
instance = Instance.new(Attributes.length + 1)
Attributes.length.times do |i|
instance.setValue(Attributes[i], features[i])
end
instance.setValue(Label, label)
instance.setDataset dataset
dataset.add instance
end

end

# -- routine to read in a UCI dataset, and return a list of UciInstances
# -- modify the parts to extract the features and class label, based on
# input data format
def read_csv filename
data = []
File.open(filename, "r") do |f|
f.each_line do |line|
items = line.rstrip.split(",")
features = items[1, items.length-1]
class_label = items[0]
data << UciInstance.new(features, class_label)
end
end
return data
end

DataSet = read_csv("agaricus-lepiota.data")

The above code will produce an array, DataSet, containing the items from the original dataset.

Comparing Algorithms

WEKA contains a huge number of algorithms. Just to name two, there is J48 for building decision-tree classifiers, and IB1 for constructing an instance-based classifier. Which one should we use? The answer to that question can be complex, and depends a lot on how intelligible we want the machine-learning model to be (decision trees and rules are much better for explaining outcomes than neural networks, for example). But a straightforward empirical question is to ask which of the two produces the best result on a given dataset. There is a fairly standard set of comparison techniques in machine learning, all based around the idea of a held-out test set, which is a dataset not used in training the model.

A held-out test set is required to prevent the following algorithm being the best learning algorithm:
  1. When learning, remember every training instance and its classification.

  2. When tested, see if the test instance is one of the ones we know.

    • If it is, then return the classification we already know.

    • If it is not, then return a random result.
This algorithm will always produce perfect results on the training material, but will only give chance results on new data. The algorithm does not generalise. We actually don't care how well the trained model performs on the training data, what we care about is how well the model performs on new data. This is why we use a held-out test set to see how well the model works on new data.

The simplest approach is to split the complete dataset into a Training and a Test partition. We build the classifiers on the Training set and then evaluate both on the same Test set. In this way, the only variable is the type of learning algorithm - the training data and test data are the same. (A more precise way of evaluating algorithms is based on cross validation.)

Constructing the datasets

In the earlier section, we used a function to construct an array of UciInstance objects from the data file. To build the training and test sets, we first partition that array into two sections, based on a target proportion. Then we need to construct a WekaTrainingSet and WekaTestSet, which are instances of the WEKA Instances class.

# Randomly partition given array of items into two arrays, using given proportion
def split_array(array, proportion)
array1, array2 = [], []
array.each do |item|
if rand < proportion
array1 << item
else
array2 << item
end
end

return [array1, array2]
end

TrainingSet, TestSet = split_array(DataSet, 0.1)

WekaTrainingSet = Instances.new("Mushrooms training dataset",
make_vector(*(Attributes.clone << Label)),
TrainingSet.length)
WekaTrainingSet.setClassIndex(WekaTrainingSet.numAttributes() - 1)
WekaTestSet = Instances.new("Mushrooms test dataset",
make_vector(*(Attributes.clone << Label)),
TestSet.length)
WekaTestSet.setClassIndex(WekaTestSet.numAttributes() - 1)

TrainingSet.each { |instance| instance.make_instance(WekaTrainingSet) }
TestSet.each { |instance| instance.make_instance(WekaTestSet) }

puts "Training set size: #{WekaTrainingSet.numInstances}"
puts "Test set size: #{WekaTestSet.numInstances}"

Constructing the models

Building the models is simple using WEKA's API. We simply create instances of the classifiers and train them on the training set:

# == kNN classifier
include_class "weka.classifiers.lazy.IB1"

ib1 = IB1.new
ib1.buildClassifier WekaTrainingSet

# == J48 classifier
include_class "weka.classifiers.trees.J48"

j48 = J48.new
j48.buildClassifier WekaTrainingSet


Evaluating

The evaluation procedure can be wrapped into a function which takes a classifier and a dataset, and returns the percentage of the dataset which the classifier has got correct:

# Check result of classifying each instance against the target result
# and return percentage correct
def evaluate_classifier(classifier, testset)
num_correct = 0
testset.numInstances.times do |i|
begin
if testset.instance(i).classValue == classifier.classifyInstance(testset.instance(i))
num_correct += 1
end
rescue java.lang.Exception
end
end
return 100*num_correct.quo(testset.numInstances)
end


puts "Performance of IB1 is: #{evaluate_classifier(ib1, WekaTestSet)}%"
puts "Performance of J48 is: #{evaluate_classifier(j48, WekaTestSet)}%"


Result

When the above is run, we get an output like:

Training set size: 787
Test set size: 7337
Performance of IB1 is: 100.0%
Performance of J48 is: 99.90459315796647%

which tells us that both algorithms do very well on the test set when trained on only 10% of the original dataset.

Summary

That is a lot of work, initially, to set up the functions to interact with WEKA. Looking back, we see that UciInstance, evaluate_classifier, and the code to convert an array of UciInstance objects into an example of WEKA Instances are all reusable pieces of code. They can be packaged up into a library file to be used in future work with WEKA and similar datasets.

Accessing WEKA from jRuby

WEKA (http://www.cs.waikato.ac.nz/ml/weka/) is a data mining platform and library, written in Java. The platform contains implementations of numerous machine-learning algorithms and data-processing filters. The best way of getting hold of the system is to download the zip file, described as 'for Linux'. Unpack the zipped folder, and copy the 'weka.jar' file to your working directory; everything else in the folder is documentation or examples, and not necessary to work with WEKA.

You will also need jruby (http://jruby.codehaus.org/); I have renamed the jruby jar to simply jruby.jar. When I wrote this, WEKA was at version 3.6.1 and jruby was at version 1.3.0.

The graphical interface for WEKA can be called up by entering 'java -jar weka.jar' on the command prompt. However, if you add weka.jar to the CLASSPATH, the classes in weka.jar become accessible as an API. The API is documented in javadoc format: point your browser at the file 'documentation.html' within the weka folder, and click on 'Package Documentation'.

Assuming weka.jar, jruby.jar, and your program are in the same folder, the calling format is:
  1. Write a ruby program, and store in a file, say 'wekascript.rb'
  2. java -jar jruby.jar wekascript.rb
Further command line arguments can be provided, and picked up in the Ruby program through ARGV, as we shall see shortly. (You can streamline this process by adding weka.jar permanently to your CLASSPATH environment variable if you wish.)

Clustering Data using WEKA from jRuby

jRuby provides easy access to Java classes and methods, and WEKA is no exception. The following program builds a simple kmeans clusterer on a supplied input file, and then prints out the assigned cluster for each data instance. The 'include_class' statements are there to simplify references to classes in the API. When classifying each instance, we must watch for the exception thrown in case a classification cannot be made. Finally, notice that the filename is passed as a command-line parameter: the parameters after the name of the jruby program are packaged up into ARGV in the usual ruby style.
# Weka scripting from jruby
# Written by Peter Lane, 2009.
# Run with: java -jar jruby.jar trial-kmeans.rb FILENAME.arff

require "java"
require "weka"

include_class "java.io.FileReader"
include_class "weka.clusterers.SimpleKMeans"
include_class "weka.core.Instances"

# load data file
file = FileReader.new ARGV[0]
data = Instances.new file

# create the model
kmeans = SimpleKMeans.new
kmeans.buildClusterer data

# print out the built model
print kmeans

# Display the cluster for each instance
data.numInstances.times do |i|
cluster = "UNKNOWN"
begin
cluster = kmeans.clusterInstance(data.instance(i))
rescue java.lang.Exception
end
puts "#{data.instance(i)},#{cluster}"
end

We can see that the WEKA api makes it easy to pass in a data file. Data can be in a number of formats, including ARFF and CSV.

When run on the weather.arff example (in WEKA's 'data' folder), the output looks like the following:

kMeans
======

Number of iterations: 3
Within cluster sum of squared errors: 16.237456311387238
Missing values globally replaced with mean/mode

Cluster centroids:
Cluster#
Attribute Full Data 0 1
(14) (9) (5)
==============================================
outlook sunny sunny overcast
temperature 73.5714 75.8889 69.4
humidity 81.6429 84.1111 77.2
windy FALSE FALSE TRUE
play yes yes yes


sunny,85,85,FALSE,no,0
sunny,80,90,TRUE,no,0
overcast,83,86,FALSE,yes,0
rainy,70,96,FALSE,yes,0
rainy,68,80,FALSE,yes,0
rainy,65,70,TRUE,no,1
overcast,64,65,TRUE,yes,1
sunny,72,95,FALSE,no,0
sunny,69,70,FALSE,yes,0
rainy,75,80,FALSE,yes,0
sunny,75,70,TRUE,yes,1
overcast,72,90,TRUE,yes,1
overcast,81,75,FALSE,yes,0
rainy,71,91,TRUE,no,1


Using jRuby to Create Instances

One of the advantages of using a language like jruby to talk to WEKA is that we should have more control on how our data is constructed and passed to the machine-learning algorithms. A good start is how to construct our own set of instances, rather than reading them directly in from file. There are some quirks to WEKA's construction of a set of Instances. In particular, each attribute must be defined through an instance of the Attribute class. This class gives a string name to the attribute and, if the attribute is a nominal attribute, the class also holds a vector of the nominal values. Each instance can then be constructed and added to the growing set of Instances. The code below shows how to 'by-hand' construct a dataset which can then be passed to one of WEKA's learning algorithms.
require "java"
require "weka"

include_class "weka.clusterers.SimpleKMeans"
include_class "weka.core.Instances"
include_class "weka.core.Instance"
include_class "weka.core.Attribute"
include_class "weka.core.FastVector"

# Convert provided arguments into a FastVector
# -- use mainly in making nominal attributes
def make_vector *args
atts = FastVector.new(args.length)
args.each {|arg| atts.addElement arg}
return atts
end

# Create the five attributes
outlook = Attribute.new("outlook", make_vector("sunny", "overcast", "rainy"))
temperature = Attribute.new("temperature")
humidity = Attribute.new("humidity")
windy = Attribute.new("windy", make_vector("FALSE", "TRUE"))
play = Attribute.new("class", make_vector("yes", "no"))

# Instances class will hold the complete dataset
dataset = Instances.new(
"weather dataset",
make_vector(outlook, temperature, humidity, windy, play),
14)

# Dataset
DATA = [
["sunny", 85, 85, "FALSE", "no"],
["sunny", 80, 90, "TRUE", "no"],
["overcast", 83, 86, "FALSE", "yes"],
["rainy", 70, 96, "FALSE", "yes"],
["rainy", 68, 80, "FALSE", "yes"],
["rainy", 65, 70, "TRUE", "no"],
["overcast", 64, 65, "TRUE", "yes"],
["sunny", 72, 95, "FALSE", "no"],
["sunny", 69, 70, "FALSE", "yes"],
["rainy", 75, 80, "FALSE", "yes"],
["sunny", 75, 70, "TRUE", "yes"],
["overcast", 72, 90, "TRUE", "yes"],
["overcast", 81, 75, "FALSE", "yes"],
["rainy", 71, 91, "TRUE", "no"]
]

# Convert raw data into Instances
DATA.each do |defn|
instance = Instance.new 5
instance.setValue(outlook, defn[0])
instance.setValue(temperature, defn[1])
instance.setValue(humidity, defn[2])
instance.setValue(windy, defn[3])
instance.setValue(play, defn[4])
instance.setDataset dataset
dataset.add instance
end

puts "Created dataset:"
puts dataset # outputs ARFF format of data

puts "\nClusters from k-means"
# create the model
kmeans = SimpleKMeans.new
kmeans.buildClusterer dataset

# Display the cluster for each instance
dataset.numInstances.times do |i|
cluster = "UNKNOWN"
begin
cluster = kmeans.clusterInstance(dataset.instance(i))
rescue java.lang.Exception
end
puts "#{dataset.instance(i)}, #{cluster}"
end